24 Commits

Author SHA1 Message Date
Jaakko Vanhala
ac15336c9f Stop-sekvenssit: katkaistaan myös "// Example usage" ja "# Example" kommentit
Malli tuottaa toisinaan esimerkkikoodia funktioiden jälkeen joka ei ole osa
varsinaista vastausta. Nyt generointi katkeaa ennen näitä.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:21:40 +03:00
Jaakko Vanhala
7a15cacebf Malli säilyy refreshin yli: automaattinen uudelleenlataus IndexedDB-cachesta
- coderSize tallennetaan localStorageen (valinta säilyy)
- Kun malli on kerran ladattu, 'kpn-coder-loaded' lippu asetetaan
- Sivulatauksessa: jos lippu on asetettu, ensureCoderNode() käynnistyy
  automaattisesti — painot tulevat IndexedDB-cachesta, ei verkosta
- Radio-napit asetetaan oikeaan tilaan localStoragesta

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:13:33 +03:00
Jaakko Vanhala
27135a8f14 Numeroidut mallilistat: kpn models ja kpn load tukevat numerovalintaa
kpn models näyttää:
  1  qwen-coder     Qwen2.5-Coder:0.5B  ~990 MB
  2  qwen-coder-3b  Qwen2.5-Coder:3B    ~6.2 GB
  3  smollm-135m    SmolLM 135M         ~270 MB
  ...

kpn load näyttää ladattavat mallit ja hyväksyy numeron:
  kpn load     → näytä lista
  kpn load 1   → lataa 0.5B
  kpn load 2   → lataa 3B
  kpn load 3b  → toimii myös nimellä

Jo ladattu malli merkitään ✓-merkillä.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:58:50 +03:00
Jaakko Vanhala
e28a715f32 Max tokens 128→256 + coder-3b malli agents-terminaaliin
- Oletustokenimäärä nostettu 256:een (monimutkaisemmat vastaukset mahtuvat)
- kpn run coder-3b "..." käynnistää 3B-mallin (parempi koodinlaatu)
- kpn load 3b lataa 3B-mallin (~6.2 GB)
- Tab-completion tukee coder-3b + esimerkkipromptit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:56:56 +03:00
Jaakko Vanhala
24d29d9ba9 Avatar-aktivointi vain omille agents-tehtäville, ei broadcast-viesteille
Agenttiavatarit vilkkuivat itsestään koska llm_prompt-handler reagoi kaikkiin
broadcastattuihin viesteihin (hubin automaattiset 10s-tehtävät, warmup jne.).
Nyt avatar-logiikka laukeaa VAIN jos viestissä on task_id joka löytyy
activeStreams:stä — eli kyseessä on käyttäjän oma agents-pipelinen tehtävä.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:55:58 +03:00
Jaakko Vanhala
7eca426e77 strip_markdown_wrapper robustimmaksi: whitelist-kielitunnisteet + tarkempi ```-poisto
Edelliset heuristiikat olivat hauraita:
- Kielitunniste tunnistettiin "lyhyt alphanumeerinen rivi" → osui koodiin (i, 42)
- rfind("```") poisti koodin sisäisiä backtickejä

Korjaukset:
- Kielitunniste poistetaan VAIN jos se on tunnettu (LANG_TAGS whitelist, 50+ kieltä)
- Sulkeva ``` poistetaan VAIN jos se on omalla rivillään tiedoston lopussa
  (ends_with tarkistus + edeltävä rivinvaihto)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:10:48 +03:00
Jaakko Vanhala
7a1352ead7 Korjattu strip_markdown_wrapper yhteensopivaksi prefill-tekniikan kanssa
Prefill lisää ``` prompttiin jolloin malli tuottaa: "rust\nfn main()...\n```"
Vanha stripperi etsi aloittavaa ```-blokkia ja palautti tyhjän.
Uusi logiikka:
1. Poistaa kielitunnisteen ensimmäiseltä riviltä (rust, python jne.)
2. Poistaa sulkevan ``` lopusta (rfind, varmistaa ettei ole koodin sisällä)
3. Poistaa johdantolauseet ja selityskommentit kuten ennenkin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:07:19 +03:00
Jaakko Vanhala
b9017448d8 BUILDING_BLOCKS.md: rakennuspalaset ja työnkulut jatkokehitystä varten
Dokumentoi kaikki arkkitehtuuripatternit, UI-komponentit ja työnkulut:
- WebSocket-reaaliaikakommunikaatio (broadcast, reititys, busy-state, työjono)
- Wasm-laskentasolmun elinkaari ja kolmitasoinen cache
- LLM-inferenssipipeline (prefill, sampling, stop-sekvenssit, streaming)
- Terminaaliemulaattori (tab-completion, dropdown, historia)
- Status-palkit ja tilaindikaattorit
- Tietoturva (XSS, rate limiting, viestityyppivalidointi, gamification-esto)
- Agenttien orkestrointi (pipeline, promptien hallinta)
- Teknologiapino ja jatkokehitysideat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:06:05 +03:00
Jaakko Vanhala
3d1b406e8d Alasvetovalikko kpn-terminaalin autocompletioniin
TAB avaa dropdown-valikon käytettävissä olevista vaihtoehdoista:
- Nuolilla (ylös/alas) navigointi
- Enter tai TAB valitsee korostetun vaihtoehdon
- Esc sulkee valikon
- Klikkaus valitsee suoraan
- Yksi vaihtoehto → täydennetään suoraan ilman valikkoa

Valikko näyttää kontekstin mukaan: alikomennot, mallit/agentit
tai esimerkkiprompteja. Sulkeutuu automaattisesti kun klikataan muualle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:53:47 +03:00
Jaakko Vanhala
aa6c4739dd Shift-TAB poistaa viimeisen sanan kpn-terminaalissa
Poistaa viimeisen sanan tai lainausmerkeissä olevan kokonaisuuden:
- "kpn run coder " → Shift-TAB → "kpn run "
- 'kpn run coder "hello world"' → Shift-TAB → "kpn run coder "

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:51:39 +03:00
Jaakko Vanhala
cbbf427a93 Tab-completion kpn-terminaaliin: ennustava komennonsyöttö sana kerrallaan
TAB täydentää kontekstin mukaan:
- tyhjä → "kpn "
- "kpn " → "kpn help", "kpn run", "kpn pipeline" jne.
- "kpn run " → agentit ja mallit (coder, manager, qwen-coder...)
- "kpn run coder " → esimerkkiprompteja ("hello world in python")
- "kpn pi" → "kpn pipeline "
- osittainen sana → yhteinen etuliite tai ainoa vaihtoehto

Tukee myös kpn pipeline -esimerkkiprompteja.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:51:01 +03:00
Jaakko Vanhala
0a216f19e2 Laskentasolmun käynnistys käyttäjän hallinnassa: nappi + cancel + kpn load
Status-palkissa "Alusta laskentasolmu" -nappi joka:
- Klikkaa → käynnistää kielimallin latauksen omalle koneelle
- Latauksen aikana muuttuu "Peruuta"-napiksi (punainen)
- Valmis → vihreä "✓ Valmis" -tila
Myös kpn load -komento terminaalissa tekee saman.
Agents-sivulla ei enää automaattista käynnistystä — käyttäjä valitsee itse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:50:07 +03:00
Jaakko Vanhala
a2e7ed53ff Laskentavalmiuden indikaattori agents-sivulle + kpn load -komento
Status-palkissa näkyy nyt Hub-yhteyden lisäksi laskentasolmun tila:
- Harmaa "Ei käynnissä ⟩" — klikkaa käynnistääksesi
- Keltainen "Ladataan mallia..." — malli latautuu
- Vihreä "Valmis (Qwen2.5-Coder)" — valmis laskentaan

Kaksi tapaa käynnistää:
1. Klikkaa compute-statusta status-palkissa
2. Kirjoita terminaaliin: kpn load

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:48:50 +03:00
Jaakko Vanhala
950cae9d96 Agents-sivu käynnistää oman laskentasolmun: käyttäjän kone valjastetaan laskentaan
Kun käyttäjä avaa #agents-sivun, käynnistetään automaattisesti Wasm coder-node
jotta tehtävät reitittyvät omalle koneelle eikä ulkoisille solmuille.
Sama logiikka kuin codelabissa (ensureCoderNode + warmup).
Toimii sekä suoralla #agents-navigoinnilla että tab-vaihdolla.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:45:29 +03:00
Jaakko Vanhala
ff3a720b8d Proaktiivinen mallin esilataus: warmup-prompt käynnistyksessä
Kun coder-node yhdistyy hubiin, lähetetään automaattisesti 1 tokenin
warmup-prompt joka triggeröi mallin latauksen (painot + rakennus).
Pipeline-tilakone merkkaa kaikki vaiheet valmiiksi logiviestien perusteella
ilman että käyttäjän tarvitsee odottaa ensimmäiseen prompttiin asti.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:38:02 +03:00
Jaakko Vanhala
6f14614af8 Syntaksikorostus agents-terminaalin ja network-näkymän LLM-vastauksiin
Highlight.js:n automaattinen kielentunnistus nyt myös agents-terminaalin
koodivastauksissa ja network-näkymän chatBoxissa (aiemmin vain codelabissa).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:28:24 +03:00
Jaakko Vanhala
518c6dc5cb Prefill-tekniikka: pakotetaan LLM-vastaus alkamaan suoraan koodilla
Assistantin vastauksen alkuun syötetään valmiiksi backtick-koodiblokki,
jolloin malli jatkaa suoraan koodilla eikä tuota "Sure! Here is..."
-johdantotekstejä. Säästää tokeneita ja vastausaikaa.
strip_markdown_wrapper poistaa ``` -merkit jälkikäteen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:27:28 +03:00
Jaakko Vanhala
b48eeb6f5f Poistetaan selityskommentit LLM-vastauksista: "# This is a simple program..." -tyyppiset rivit
Malli tuottaa toisinaan selityskommentin koodin alkuun ilman markdown-wrapperia.
Stripperi tunnistaa ja poistaa nämä avainsanojen perusteella (this is, simple,
program that, jne.) mutta säilyttää oikeat koodikommentit ja shebangin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:26:26 +03:00
Jaakko Vanhala
6bc7d03676 Reitityksen tilatieto UI:ssa: task_routed-viestit terminaaliin ja codelab-latausindikaattoriin
Näyttää "Reititetty solmulle #N" tai "Kaikki N solmua varattuja — odotetaan..."
sekä agents-terminaalissa että koodilaboratorion lataustekstissä.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:25:43 +03:00
Jaakko Vanhala
13b2911d38 Tehtävien reitityksen tilatieto ja työjono: task_routed-viesti UI:lle, 30s jono kun solmut varattuja
Hub broadcastaa task_routed-viestin joka kertoo reitityksen tilan:
- "routed": vapaa solmu löytyi, tehtävä reititetty suoraan
- "queued": kaikki solmut varattuja, odotetaan vapautumista (max 30s poll)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:25:18 +03:00
Jaakko Vanhala
38054452e2 Pipeline-tilakone: matchaa myös [Storage]-prefiksin tokenizer-logit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:19:03 +03:00
Jaakko Vanhala
50ff34cb09 Highlight.js koodin syntaksikorostukseen (automaattinen kielentunnistus) + markdown-strippaus
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:43:20 +03:00
Jaakko Vanhala
949f34833f Markdown-wrapper strippaus LLM-vastauksista + hub-status tooltip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:41:23 +03:00
Jaakko Vanhala
88fd31ca8c Hub-yhteyden tila omaksi status-palkiksi terminaalin yläpuolelle (agents-näkymä)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:39:48 +03:00
5 changed files with 1166 additions and 90 deletions

View File

@@ -0,0 +1,525 @@
# Kipinä Agentic Studio — Rakennuspalaset
Tämä dokumentti kuvaa projektin UI-komponentit, arkkitehtuuripatternit ja työnkulut niin, että vastaavan hajautetun AI-laskentaverkon ja agenttipohjaisen käyttöliittymän voi rakentaa alusta asti.
## Yleiskuva
```
┌─────────────────────────────────────────────────────┐
│ Selain (käyttäjä) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Verkko- │ │ Koodi- │ │ Agents-näkymä │ │
│ │ näkymä │ │ labra │ │ ┌───────────────┐ │ │
│ │ │ │ │ │ │ Terminaali │ │ │
│ │ Stats │ │ Editor │ │ │ Tab-complete │ │ │
│ │ Chat │ │ Pipeline │ │ │ Dropdown │ │ │
│ │ Tokenit │ │ Tulokset │ │ │ Historia │ │ │
│ └────┬─────┘ └────┬─────┘ │ └───────────────┘ │ │
│ │ │ └────────┬──────────┘ │
│ └──────────┬───┘ │ │
│ UI WebSocket HTTP API │
│ │ /api/v1/chat │
│ ┌───────────────┴──────────────┐ │ │
│ │ Wasm Compute Node │ │ │
│ │ (Candle + Burn) │ │ │
│ │ ┌─────────┐ ┌────────────┐ │ │ │
│ │ │ RAM │ │ IndexedDB │ │ │ │
│ │ │ Cache │ │ Cache │ │ │ │
│ │ └─────────┘ └────────────┘ │ │ │
│ │ ┌─────────────────────────┐ │ │ │
│ │ │ Model Cache (QwenModel) │ │ │ │
│ │ └─────────────────────────┘ │ │ │
│ └──────────────┬───────────────┘ │ │
│ │ WS │ │
└─────────────────┼──────────────────────┼─────────────┘
│ │
┌────────┴──────────────────────┴──┐
│ Hub (Axum + Tokio) │
│ ┌────────────┐ ┌─────────────┐ │
│ │ Broadcast │ │ Node │ │
│ │ Channel │ │ Registry │ │
│ └────────────┘ └─────────────┘ │
│ ┌────────────┐ ┌─────────────┐ │
│ │ Busy-State │ │ Rate Limit │ │
│ │ Tracker │ │ + Auth │ │
│ └────────────┘ └─────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ SQLite (sessiot, tulokset) │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────┘
```
---
## 1. WebSocket-reaaliaikakommunikaatio
### 1.1 Hub ↔ Node broadcast-kanava
**Tarkoitus:** Jakaa tehtäviä ja vastaanottaa tuloksia kaikilta laskentasolmuilta.
**Työnkulku:**
1. Hub luo `tokio::sync::broadcast::channel(100)`
2. Jokainen solmu saa oman `rx = stats_tx.subscribe()`
3. Hub broadcastaa tehtävät: `stats_tx.send(json)`
4. Solmut suodattavat viestin tyypin ja `selected_task`:n perusteella
**Viestityupit:**
| Tyyppi | Suunta | Sisältö |
|--------|--------|---------|
| `stats` | Hub → kaikki | nodes, vram_gb, tasks |
| `pair_task` | Hub → tokenize-solmut | en, fi tekstiparit |
| `llm_prompt` | Hub → valittu solmu | prompt, model, task_id |
| `llm_chunk` | Solmu → Hub → UI | token (1 kerrallaan) |
| `llm_done` | Solmu → Hub → UI | response, tokens_generated, duration_ms |
| `llm_error` | Solmu → Hub → UI | error, task_id |
| `task_routed` | Hub → UI | status (routed/queued), node_id, message |
**Lagged-viestien käsittely:**
```rust
match rx.recv().await {
Ok(msg) => { /* käsittele */ }
Err(broadcast::error::RecvError::Lagged(n)) => {
// Ohitetaan vanhat viestit, ei katkaista yhteyttä
continue;
}
Err(_) => break, // Kanava suljettu
}
```
### 1.2 Kohdennettu reititys (Direct Channel)
**Tarkoitus:** Lähetä tehtävä yhdelle tietylle solmulle broadcastin sijaan.
**Työnkulku:**
1. Jokainen solmu saa `mpsc::unbounded_channel` yhdistyessään
2. Hub tallentaa `node_channels: HashMap<u64, UnboundedSender>`
3. API-pyyntö → valitaan vapaa solmu → lähetetään suoraan kanavaan
4. Broadcast-kanavaa käytetään vain tuloksen välittämiseen UI:lle
```rust
let channels = state.node_channels.read().await;
if let Some(tx) = channels.get(&target_node_id) {
tx.send(msg.to_string());
}
```
### 1.3 Busy-state ja työjono
**Tarkoitus:** Estä tehtävien reititys varatuille solmuille.
**Rakenne:**
- `node_busy: HashSet<u64>` — solmut joilla on aktiivinen tehtävä
- Asetetaan kun tehtävä reititetään, vapautetaan `llm_done`/`llm_error`:ssa
- Jos kaikki solmut varattuja → pollaa 500ms välein, max 30s
**UI-palaute:**
```json
{"type": "task_routed", "status": "queued", "message": "Kaikki 2 solmua varattuja — odotetaan..."}
{"type": "task_routed", "status": "routed", "node_id": 3, "message": "Solmu #3 vapautui (2.5s jonossa)"}
```
---
## 2. Wasm-laskentasolmu
### 2.1 Elinkaari
```
init() → start_agent_node(ws_url, has_webgpu, device_info, task_id)
├─ Avaa WebSocket hubiin
├─ Lähettää auth-viestin (laitetiedot, selected_task)
├─ Rekisteröityy onmessage-käsittelijä
│ ├─ pair_task → tokenize
│ ├─ llm_prompt → inference
│ └─ ai_task → tensor matmul
└─ Odottaa tehtäviä loopissa
```
**Globaali tila (atominen, lukitsematon):**
```rust
static GPU_LOAD_PERCENT: AtomicU32 = AtomicU32::new(50);
static LLM_BUSY: AtomicBool = AtomicBool::new(false);
static SELECTED_TASK: AtomicU32 = AtomicU32::new(0);
```
### 2.2 Kolmitasoinen cache
```
Pyyntö → [1] RAM-cache (thread_local HashMap)
│ miss
[2] IndexedDB (selaimen pysyvä tallennus)
│ miss
[3] Verkko (HuggingFace CDN, streaming + 5% progressi)
Tallenna → IndexedDB → RAM-cache
```
| Taso | Nopeus | Koko | Pysyvyys |
|------|--------|------|----------|
| RAM | ~0ms | Rajaton | Sivulataus |
| IndexedDB | ~50ms | ~50GB | Pysyvä |
| Verkko | ~10s/100MB | ∞ | — |
**Malliinstanssin cache (neljäs taso):**
```rust
thread_local! {
static MODEL_CACHE: RefCell<Option<CachedModel>> = RefCell::new(None);
}
// clear_kv_cache() promptien välillä — ei tarvitse rakentaa mallia uusiksi
```
### 2.3 Warmup-esilataus
**Tarkoitus:** Lataa malli valmiiksi ennen ensimmäistä oikeaa promptia.
```javascript
// Lähetetään 1 tokenin warmup heti kun WS on auki
uiSocket.send(JSON.stringify({
type: 'user_text',
text: '{"prompt":"warmup","max_tokens":1}',
task_type: 'qwen-coder'
}));
```
---
## 3. LLM-inferenssipipeline
### 3.1 Prompt-formaatti (ChatML + prefill)
```
<|im_start|>system
You are a coding assistant. Respond with ONLY code.<|im_end|>
<|im_start|>user
hello world in python<|im_end|>
<|im_start|>assistant
``` ← PREFILL: pakottaa mallin aloittamaan koodilla
```
**Prefill-tekniikka:** Lisäämällä ` ``` ` assistantin vastauksen alkuun malli jatkaa suoraan koodilla eikä tuota "Sure! Here is..." -johdantoa. Säästää 10-20 tokenia per vastaus.
### 3.2 Sampling-parametrit
| Parametri | Arvo | Tarkoitus |
|-----------|------|-----------|
| `temperature` | 0.7 | Pehmentää jakaumaa, vähentää toistoa |
| `top_k` | 40 | Rajaa valinnan 40 todennäköisimpään tokeniin |
| `repetition_penalty` | 1.15 | Rankaisee jo generoitujen tokenien uudelleenvalintaa |
| `max_tokens` | 128 | Oletusraja, JSON-promptilla konfiguroitavissa |
**Sampling-funktio (top-k + temperature + repetition penalty):**
```rust
fn sample_top_k_with_penalty(logits, k, temperature, generated_tokens, penalty) -> u32 {
// 1. Repetition penalty: vähennä aiempien tokenien logitteja
// 2. Temperature scaling: jaa logitit temperaturella
// 3. Top-k: ota k suurinta
// 4. Softmax top-k:lle
// 5. Satunnaisvalinta kumulatiivisella todennäköisyydellä (XorShift RNG)
}
```
### 3.3 Stop-sekvenssit
Generointi katkaistaan ja teksti trimmataan kun malli alkaa selittää:
```rust
let stop_patterns = ["\n###", "\nExplanation", "\nNote:", "\nOutput:", "\n```\n\n"];
```
### 3.4 Vastauksen siivous
```
Raakavastaus: "Sure! Here is...\n```python\n# This is a simple program\nprint('hi')\n```"
strip_markdown: "# This is a simple program\nprint('hi')"
strip_preamble: "print('hi')"
```
**Tunnistettavat selityskommentit:** `# This is`, `# simple`, `# program that`, `# here is`, `# the following`, `# below`
### 3.5 Streaming
Jokainen generoitu token lähetetään heti `llm_chunk`-viestinä:
```json
{"type": "llm_chunk", "token": "print", "prompt": "...", "model": "Qwen2.5-Coder", "task_id": "uuid"}
```
UI päivittää streaming-korttia reaaliaikaisesti appendaamalla tokeneita.
---
## 4. Terminaaliemulaattori
### 4.1 Rakenne
```html
<div id="agent-hub-status"> <!-- Status-palkki (Hub + Laskenta) -->
<div id="agent-terminal"> <!-- Scrollaava tulosalue, max 100 riviä -->
<div> <!-- Input-rivi -->
<span>$</span>
<input id="term-input">
<div id="term-dropdown"> <!-- Autocompletion-valikko -->
</div>
```
### 4.2 Komentojen käsittely
```javascript
function termExec(cmd) {
// Parsitaan: "kpn" + alikomento + argumentit
// Tuetut: help, run, pipeline, load, status, models, hello, clear
// Agenttinimi → malli-mapping: "coder" → "qwen-coder"
}
```
### 4.3 Tab-completion (kolmitasoinen)
```javascript
const kpnCommands = {
'kpn': ['help', 'run', 'pipeline', 'load', ...],
'kpn run': ['coder', 'manager', 'qwen-coder', ...],
};
const kpnExamples = {
'kpn run coder': ['"hello world in python"', ...],
};
```
**Käyttö:**
| Näppäin | Toiminto |
|---------|----------|
| TAB | Täydennä seuraava sana tai avaa dropdown |
| Shift-TAB | Poista viimeinen sana (lainausmerkit kokonaisuutena) |
| ↑ / ↓ | Navigoi dropdownissa (tai komentohistoriassa) |
| Enter | Valitse dropdownista tai suorita komento |
| Esc | Sulje dropdown |
### 4.4 Dropdown-valikko
```javascript
function showDropdown(items, prefix) {
// Luo div.term-dd-item per vaihtoehto
// Positio: absolute, bottom: 100% (inputin yläpuolella)
// Mouseenter → highlight, click → valinta
}
```
### 4.5 Komentohistoria
```javascript
const termHistory = []; // Kaikki ajetut komennot (viimeisin ensin)
let termHistIdx = -1; // Nykyinen positio historiassa
// ArrowUp: termHistIdx++, ArrowDown: termHistIdx--
```
---
## 5. Status-palkit ja tilaindikaattorit
### 5.1 Hub-yhteyden tila
| Tila | Väri | Teksti | Tooltip |
|------|------|--------|---------|
| Yhdistetään | 🟡 | "Yhdistetään..." | WebSocket-yhteys Kipinä Hubiin |
| Yhdistetty | 🟢 | "Yhdistetty" | Tehtävien jakelu aktiivinen |
| Katkennut | 🔴 | "Yhteys katkennut" | Tarkista verkko, lataa uudelleen |
### 5.2 Laskentasolmun tila
| Tila | Väri | Teksti | Nappi |
|------|------|--------|-------|
| Ei käynnissä | ⚫ | "—" | `[Alusta laskentasolmu]` sininen |
| Lataa | 🟡 | "Ladataan..." | `[Peruuta]` punainen |
| Valmis | 🟢 | "Qwen2.5-Coder" | `[✓ Valmis]` vihreä |
### 5.3 Pipeline-tilakone (Codelab)
```
Step 1: WebAssembly-ytimen lataus [◯ → ◷ → ✓]
Step 2: Tokenizer (7 MB) [◯ → ◷ → ✓]
Step 3: Mallipainot (990 MB) [◯ → ◷ 45% → ✓ cache]
Step 4: Mallin rakentaminen [◯ → ◷ → ✓]
Step 5: Valmis generoimaan [◯ → ✓]
```
**Seuranta console.log-viesteistä:**
```javascript
if (msg.includes('[Coder]') && msg.includes('Malli ladattu')) {
// Merkkaa kaikki vaiheet valmiiksi (myös cache-hitillä)
setStep('step-wasm', 'done');
setStep('step-tokenizer', 'done');
setStep('step-model', 'done', 'cache');
setStep('step-build', 'done');
setStep('step-ready', 'done');
}
```
---
## 6. Tietoturva
### 6.1 XSS-suojaus
```javascript
function esc(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
```
**Käyttöpaikat:** Kaikki `innerHTML`-insertoinnit joissa on käyttäjä- tai backend-dataa.
### 6.2 System prompt -piilotus
```javascript
function stripSystemPrompt(prompt) {
const parts = prompt.split('\n\n');
return parts[parts.length - 1] || prompt;
}
```
### 6.3 Viestityyppivalidointi (backend)
```rust
const ALLOWED_MSG_TYPES: &[&str] = &[
"auth", "result", "pair_done", "llm_chunk", "llm_done",
"llm_error", "download_progress", "user_text", "single_tokenize_done"
];
fn validate_message(text: &str) -> Result<Value, &'static str> {
// 1. JSON-parsinta
// 2. "type"-kenttä pakollinen
// 3. Tyyppi sallittujen listalla
// 4. Tyyppikohtainen validointi (esim. pair_done: token_count <= 10000)
}
```
### 6.4 Rate limiting
```rust
// Per-IP liukuva ikkuna: max 10 pyyntöä per 60s
let entry = limits.entry(addr.ip()).or_insert((now, 0));
if now.duration_since(entry.0).as_secs() >= 60 {
*entry = (now, 1);
} else {
entry.1 += 1;
if entry.1 > 10 { return 429 Too Many Requests; }
}
```
### 6.5 Gamification-huijauksen esto
```rust
// Hub jakaa task_id:n → tallentaa pending_task_ids:hen
// Merkkejä jaetaan VAIN jos llm_done sisältää validin task_id:n
let valid_task = state.pending_task_ids.lock().unwrap().remove(tid);
if active_incentives && valid_task {
*balance += 20;
}
```
---
## 7. Syntaksikorostus
### 7.1 Highlight.js-integraatio
```html
<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>
```
```javascript
function highlightCode(code) {
if (typeof hljs !== 'undefined') {
return hljs.highlightAuto(code).value; // Automaattinen kielentunnistus
}
return esc(code); // Fallback
}
```
**Käyttöpaikat:** Codelab-tulokset, agents-terminaalin vastaukset, network-chat.
---
## 8. Agenttien orkestrointi
### 8.1 Multi-agent pipeline
```
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Manageri │ ──→ │ Koodari │ ──→ │ Testaaja │
│ Analysoi │ │ Koodaa │ │ Arvioi │
│ tehtävä │ │ ratkaisu │ │ koodi │
└──────────┘ └──────────┘ └──────────┘
```
```javascript
async function kpnPipeline(task) {
const plan = await kpnRun('qwen-coder', `Analysoi: ${task}`);
if (!plan) return;
const code = await kpnRun('qwen-coder', `Koodaa: ${plan}`);
if (!code) return;
await kpnRun('smollm-135m', `Arvioi: ${code}`);
}
```
### 8.2 Agenttien promptien hallinta
```javascript
const agentPrompts = {
manager: { model: 'qwen-coder', prompt: 'Olet projektipäällikkö...' },
coder: { model: 'qwen-coder', prompt: 'Olet ohjelmistokehittäjä...' },
// ...
};
// Tallennetaan localStorage:en per agentti
localStorage.setItem('kpn-agent-prompt-coder', customPrompt);
```
### 8.3 Yhteinen promptikonteksti
```javascript
async function kpnRun(model, prompt) {
const parts = [];
if (sharedPrompt) parts.push(sharedPrompt); // Kaikille yhteinen
if (agent.prompt) parts.push(agent.prompt); // Agenttikohtainen
parts.push(prompt); // Käyttäjän pyyntö
const fullPrompt = parts.join('\n\n');
// → HTTP POST /api/v1/chat/completions
}
```
---
## 9. Teknologiapino
| Kerros | Teknologia | Tarkoitus |
|--------|------------|-----------|
| Frontend | Vanilla JS + HTML + CSS | Ei build-steppiä, toimii suoraan |
| Wasm | Rust + wasm-bindgen | Inferenssi selaimessa |
| LLM | Candle (Rust) | Transformer-inferenssi CPU:lla |
| Tensorit | Burn (Rust) | GPU-tensorilaskenta (WebGPU/NdArray) |
| Backend | Axum + Tokio (Rust) | Async WebSocket + HTTP -palvelin |
| Tietokanta | SQLite (rusqlite) | Sessiot ja tulokset |
| Cache | IndexedDB | Mallipainot selaimen pysyvässä muistissa |
| Korostus | Highlight.js (CDN) | Syntaksikorostus, automaattinen kielentunnistus |
| Tokenizer | HuggingFace tokenizers | BPE-tokenisaatio Wasmissa |
---
## 10. Jatkokehitysideoita
Näiden rakennuspalasten pohjalta voi rakentaa:
- **Oma chat-UI:** WebSocket + streaming + syntaksikorostus
- **Hajautettu laskentaverkko:** Hub + node-rekisteri + busy-state + työjono
- **Selain-LLM:** Wasm + Candle + IndexedDB-cache + warmup
- **Agenttipohjainen työnkulku:** Pipeline + prompt-orkestrointi + reititys
- **Terminaaliemulasttori:** Input + historia + tab-completion + dropdown
- **Reaaliaikadashboard:** WebSocket broadcast + tilaindikaattorit + metriikat

View File

@@ -972,24 +972,86 @@ async fn api_chat_completions(
} }
} }
// Etsitään ensimmäinen VAPAA solmu, joka vastaa pyydettyä mallia // Etsitään vapaa tai varattu solmu, joka vastaa pyydettyä mallia
let target_node = { let (target_node_free, target_node_any, total_matching) = {
let tasks = state.node_tasks.lock().unwrap(); let tasks = state.node_tasks.lock().unwrap();
let busy = state.node_busy.lock().unwrap(); let busy = state.node_busy.lock().unwrap();
tasks.iter().find(|(node_id, task)| { let matching: Vec<u64> = tasks.iter().filter(|(_, task)| {
let model_match = if payload.model == "qwen-coder" { if payload.model == "qwen-coder" {
*task == "qwen-coder-05b" || *task == "qwen-coder" *task == "qwen-coder-05b" || *task == "qwen-coder"
} else { } else {
**task == payload.model **task == payload.model
}; }
model_match && !busy.contains(node_id) }).map(|(k, _)| *k).collect();
}).map(|(k, _)| *k) let free = matching.iter().find(|id| !busy.contains(id)).copied();
let any = matching.first().copied();
(free, any, matching.len())
}; };
let target_node_id = match target_node { // Broadcastataan reititystila UI:lle
Some(id) => id, let task_id = payload.task_id.clone();
None => {
return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Ei vapaata solmua tälle mallille (kaikki varattuja tai ei käynnissä)").into_response(); if target_node_any.is_none() {
// Ei yhtään solmua tälle mallille
return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Ei solmua tälle mallille (käynnistä malli selaimessa)").into_response();
}
let target_node_id;
if let Some(free_id) = target_node_free {
// Vapaa solmu löytyi — reititetään suoraan
target_node_id = free_id;
let node_type = if state.node_tasks.lock().unwrap().get(&free_id).map(|t| t.contains("native")).unwrap_or(false) { "natiivi" } else { "selain" };
let routing_msg = serde_json::json!({
"type": "task_routed",
"task_id": task_id,
"node_id": free_id,
"node_type": node_type,
"status": "routed",
"message": format!("Reititetty solmulle #{}", free_id),
});
let _ = state.stats_tx.send(routing_msg.to_string());
} else {
// Kaikki solmut varattuja — odotetaan vapautumista (max 30s)
let queue_msg = serde_json::json!({
"type": "task_routed",
"task_id": task_id,
"status": "queued",
"message": format!("Kaikki {} solmua varattuja — odotetaan vapautumista...", total_matching),
});
let _ = state.stats_tx.send(queue_msg.to_string());
// Pollaa busy-tilaa 500ms välein, max 30s
let mut waited = 0u32;
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
waited += 500;
let free = {
let tasks = state.node_tasks.lock().unwrap();
let busy = state.node_busy.lock().unwrap();
tasks.iter().find(|(node_id, task)| {
let model_match = if payload.model == "qwen-coder" {
*task == "qwen-coder-05b" || *task == "qwen-coder"
} else {
**task == payload.model
};
model_match && !busy.contains(node_id)
}).map(|(k, _)| *k)
};
if let Some(id) = free {
target_node_id = id;
let routing_msg = serde_json::json!({
"type": "task_routed",
"task_id": task_id,
"node_id": id,
"status": "routed",
"message": format!("Solmu #{} vapautui — reititetään ({:.1}s jonossa)", id, waited as f64 / 1000.0),
});
let _ = state.stats_tx.send(routing_msg.to_string());
break;
}
if waited >= 30000 {
return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Aikakatkaisu: kaikki solmut varattuja 30s ajan").into_response();
}
} }
}; };
@@ -1007,14 +1069,14 @@ async fn api_chat_completions(
// Odotuskanava valmiiksi (solmu palauttaa tuloksen stats_tx kautta) // Odotuskanava valmiiksi (solmu palauttaa tuloksen stats_tx kautta)
let mut rx = state.stats_tx.subscribe(); let mut rx = state.stats_tx.subscribe();
// Kohdennettu reititys: lähetetään AI-tehtävä suoraan VAIN valitulle solmulle (Reititysarkkitehtuuri) // Kohdennettu reititys: lähetetään AI-tehtävä suoraan VAIN valitulle solmulle
{ {
let channels = state.node_channels.read().await; let channels = state.node_channels.read().await;
if let Some(tx) = channels.get(&target_node_id) { if let Some(tx) = channels.get(&target_node_id) {
let _ = tx.send(msg.to_string()); let _ = tx.send(msg.to_string());
tracing::info!("Reititettiin API-pyyntö solmulle {} (Malli: {})", target_node_id, payload.model); tracing::info!("Reititettiin API-pyyntö solmulle {} (Malli: {})", target_node_id, payload.model);
} else { } else {
return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Verkkovirhe: solmun yhteys katkesi pyynnön aikana").into_response(); return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Verkkovirhe: solmun yhteys katkesi reitityksen aikana").into_response();
} }
} }

View File

@@ -124,7 +124,8 @@ impl LlmEngine {
} }
pub fn generate(&mut self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> { pub fn generate(&mut self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> {
let formatted = format!("<|im_start|>system\nYou are a coding assistant. Respond with ONLY code. No explanations, no markdown, no comments unless asked.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", prompt); // Prefill: aloitetaan vastaus ```-koodiblokkilla → malli jatkaa suoraan koodilla
let formatted = format!("<|im_start|>system\nYou are a coding assistant. Respond with ONLY code. No explanations, no markdown, no comments unless asked.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n```\n", prompt);
let encoding = self.tokenizer.encode(formatted.as_str(), true) let encoding = self.tokenizer.encode(formatted.as_str(), true)
.map_err(|e| format!("Encode: {}", e))?; .map_err(|e| format!("Encode: {}", e))?;
@@ -206,8 +207,8 @@ impl LlmEngine {
// Stop-sekvenssit: katkaistaan kun malli alkaa selittää // Stop-sekvenssit: katkaistaan kun malli alkaa selittää
let lower = generated_text.to_lowercase(); let lower = generated_text.to_lowercase();
if lower.contains("\n###") || lower.contains("\nexplanation") || lower.contains("\nnote:") || lower.contains("\noutput:") || lower.contains("\n```\n\n") { if lower.contains("\n###") || lower.contains("\nexplanation") || lower.contains("\nnote:") || lower.contains("\noutput:") || lower.contains("\n```\n\n") || lower.contains("\n// example") || lower.contains("\n# example") {
for stop in &["\n###", "\nExplanation", "\nNote:", "\nOutput:", "\n```\n\n"] { for stop in &["\n###", "\nExplanation", "\nNote:", "\nOutput:", "\n```\n\n", "\n// Example", "\n// example", "\n# Example", "\n# example"] {
if let Some(pos) = generated_text.find(stop) { if let Some(pos) = generated_text.find(stop) {
generated_text.truncate(pos); generated_text.truncate(pos);
} }
@@ -225,7 +226,7 @@ impl LlmEngine {
} else { 0.0 }; } else { 0.0 };
Ok(GenerateResult { Ok(GenerateResult {
text: generated_text, text: strip_markdown_wrapper(&generated_text),
tokens_generated, tokens_generated,
duration_ms: gen_time.as_millis() as f64, duration_ms: gen_time.as_millis() as f64,
tokens_per_sec, tokens_per_sec,
@@ -233,6 +234,61 @@ impl LlmEngine {
} }
} }
const LANG_TAGS: &[&str] = &[
"python", "py", "rust", "rs", "javascript", "js", "typescript", "ts",
"java", "kotlin", "scala", "go", "ruby", "rb", "php", "swift",
"c", "cpp", "c++", "c#", "csharp", "r", "sql", "bash", "sh", "zsh",
"html", "css", "json", "yaml", "yml", "toml", "xml", "markdown", "md",
"lua", "perl", "dart", "elixir", "haskell", "hs", "ocaml", "zig",
"plaintext", "text", "txt",
];
/// Siivoa mallin tuottama vastaus (prefill-yhteensopiva).
fn strip_markdown_wrapper(text: &str) -> String {
let mut result = text.trim().to_string();
// 1. Kielitunniste — VAIN tunnettu kieli
if let Some(nl) = result.find('\n') {
let first = result[..nl].trim().to_lowercase();
if LANG_TAGS.contains(&first.as_str()) {
result = result[nl + 1..].to_string();
}
}
// 2. Sulkeva ``` — VAIN omalla rivillään lopussa
let trimmed = result.trim_end();
if trimmed.ends_with("```") {
let before = &trimmed[..trimmed.len() - 3];
if before.is_empty() || before.ends_with('\n') {
result = before.trim_end().to_string();
}
}
// 3. Johdantolauseet
let lower = result.trim().to_lowercase();
for prefix in &["sure!", "here is", "here's", "certainly!", "below is"] {
if lower.starts_with(prefix) {
if let Some(nl) = result.find('\n') { result = result[nl + 1..].to_string(); }
break;
}
}
// 4. Selityskommentit alusta
let mut lines: Vec<&str> = result.trim().lines().collect();
while !lines.is_empty() {
let first = lines[0].trim();
let is_preamble = first.starts_with("# ") && !first.starts_with("#!")
&& (first.to_lowercase().contains("this is")
|| first.to_lowercase().contains("simple")
|| first.to_lowercase().contains("program that")
|| first.to_lowercase().contains("here is")
|| first.to_lowercase().contains("the following")
|| first.to_lowercase().contains("below"));
if is_preamble { lines.remove(0); } else { break; }
}
lines.join("\n").trim().to_string()
}
pub struct GenerateResult { pub struct GenerateResult {
pub text: String, pub text: String,
pub tokens_generated: usize, pub tokens_generated: usize,

View File

@@ -27,6 +27,69 @@ struct CachedModel {
is_3b: bool, is_3b: bool,
} }
/// Tunnetut kielitunnisteet joita malli voi tuottaa prefill-backtickien jälkeen.
const LANG_TAGS: &[&str] = &[
"python", "py", "rust", "rs", "javascript", "js", "typescript", "ts",
"java", "kotlin", "scala", "go", "ruby", "rb", "php", "swift",
"c", "cpp", "c++", "c#", "csharp", "r", "sql", "bash", "sh", "zsh",
"html", "css", "json", "yaml", "yml", "toml", "xml", "markdown", "md",
"lua", "perl", "dart", "elixir", "haskell", "hs", "ocaml", "zig",
"plaintext", "text", "txt",
];
/// Siivoa mallin tuottama vastaus.
/// Prefill-tekniikan vuoksi malli tuottaa: "rust\nfn main() {...}\n```"
/// eli kielitunniste alussa + sulkeva ``` lopussa. Molemmat poistetaan.
fn strip_markdown_wrapper(text: &str) -> String {
let mut result = text.trim().to_string();
// 1. Poistetaan kielitunniste ensimmäiseltä riviltä — VAIN jos se on tunnettu kieli
if let Some(first_newline) = result.find('\n') {
let first_line = result[..first_newline].trim().to_lowercase();
if LANG_TAGS.contains(&first_line.as_str()) {
result = result[first_newline + 1..].to_string();
}
}
// 2. Poistetaan sulkeva ``` VAIN jos se on omalla rivillään lopussa
let trimmed = result.trim_end();
if trimmed.ends_with("```") {
let before = &trimmed[..trimmed.len() - 3];
// Varmistetaan: edellinen merkki on rivinvaihto tai alku (eli ``` on oma rivinsä)
if before.is_empty() || before.ends_with('\n') {
result = before.trim_end().to_string();
}
}
// 3. Poistetaan johdantolauseet: "Sure! Here is...", "Certainly!" jne.
let lower = result.trim().to_lowercase();
for prefix in &["sure!", "here is", "here's", "certainly!", "below is"] {
if lower.starts_with(prefix) {
if let Some(newline) = result.find('\n') {
result = result[newline + 1..].to_string();
}
break;
}
}
// 4. Poistetaan selityskommentit alusta: "# This is a simple program..."
let mut lines: Vec<&str> = result.trim().lines().collect();
while !lines.is_empty() {
let first = lines[0].trim();
let is_preamble = first.starts_with("# ")
&& !first.starts_with("#!")
&& (first.to_lowercase().contains("this is")
|| first.to_lowercase().contains("simple")
|| first.to_lowercase().contains("program that")
|| first.to_lowercase().contains("here is")
|| first.to_lowercase().contains("the following")
|| first.to_lowercase().contains("below"));
if is_preamble { lines.remove(0); } else { break; }
}
lines.join("\n").trim().to_string()
}
thread_local! { thread_local! {
static RAM_CACHE: RefCell<std::collections::HashMap<String, Rc<Vec<u8>>>> = RefCell::new(std::collections::HashMap::new()); static RAM_CACHE: RefCell<std::collections::HashMap<String, Rc<Vec<u8>>>> = RefCell::new(std::collections::HashMap::new());
static MODEL_CACHE: RefCell<Option<CachedModel>> = RefCell::new(None); static MODEL_CACHE: RefCell<Option<CachedModel>> = RefCell::new(None);
@@ -195,16 +258,18 @@ pub async fn run_coder_inference(prompt: String, ws: Rc<RefCell<WebSocket>>, use
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&prompt) { if let Ok(json) = serde_json::from_str::<serde_json::Value>(&prompt) {
let p = json.get("prompt").and_then(|v| v.as_str()).unwrap_or(&prompt).to_string(); let p = json.get("prompt").and_then(|v| v.as_str()).unwrap_or(&prompt).to_string();
let s = json.get("system").and_then(|v| v.as_str()).unwrap_or(default_system).to_string(); let s = json.get("system").and_then(|v| v.as_str()).unwrap_or(default_system).to_string();
let m = json.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(128) as usize; let m = json.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(256) as usize;
(p, s, m) (p, s, m)
} else { } else {
(prompt.clone(), default_system.to_string(), 128) (prompt.clone(), default_system.to_string(), 256)
} }
} else { } else {
(prompt.clone(), default_system.to_string(), 128) (prompt.clone(), default_system.to_string(), 256)
}; };
let formatted = format!("<|im_start|>system\n{}<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", system_msg, actual_prompt); // Prefill: aloitetaan vastaus ```-koodiblokkilla, jolloin malli jatkaa suoraan koodilla
// eikä tuota "Sure! Here is..." -johdantoa. strip_markdown_wrapper poistaa ``` jälkikäteen.
let formatted = format!("<|im_start|>system\n{}<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n```\n", system_msg, actual_prompt);
// Inferenssi: käytetään välimuistissa olevaa mallia // Inferenssi: käytetään välimuistissa olevaa mallia
let (generated_text, tokens_generated, gen_time) = MODEL_CACHE.with(|cache| { let (generated_text, tokens_generated, gen_time) = MODEL_CACHE.with(|cache| {
@@ -277,8 +342,8 @@ pub async fn run_coder_inference(prompt: String, ws: Rc<RefCell<WebSocket>>, use
// Stop-sekvenssit: katkaistaan kun malli alkaa selittää // Stop-sekvenssit: katkaistaan kun malli alkaa selittää
let lower = generated_text.to_lowercase(); let lower = generated_text.to_lowercase();
if lower.contains("\n###") || lower.contains("\nexplanation") || lower.contains("\nnote:") || lower.contains("\noutput:") || lower.contains("\n```\n\n") { if lower.contains("\n###") || lower.contains("\nexplanation") || lower.contains("\nnote:") || lower.contains("\noutput:") || lower.contains("\n```\n\n") || lower.contains("\n// example") || lower.contains("\n# example") {
for stop in &["\n###", "\nExplanation", "\nNote:", "\nOutput:", "\n```\n\n"] { for stop in &["\n###", "\nExplanation", "\nNote:", "\nOutput:", "\n```\n\n", "\n// Example", "\n// example", "\n# Example", "\n# example"] {
if let Some(pos) = generated_text.find(stop) { if let Some(pos) = generated_text.find(stop) {
generated_text.truncate(pos); generated_text.truncate(pos);
} }
@@ -295,7 +360,11 @@ pub async fn run_coder_inference(prompt: String, ws: Rc<RefCell<WebSocket>>, use
} }
let gen_time = perf.now() - start_gen; let gen_time = perf.now() - start_gen;
(generated_text, tokens_generated, gen_time)
// Siivotaan vastaus: poista markdown-koodiblokit ja johdantotekstit
let cleaned = strip_markdown_wrapper(&generated_text);
(cleaned, tokens_generated, gen_time)
}); });
let tokens_per_sec = if gen_time > 0.0 { (tokens_generated as f64 / gen_time) * 1000.0 } else { 0.0 }; let tokens_per_sec = if gen_time > 0.0 { (tokens_generated as f64 / gen_time) * 1000.0 } else { 0.0 };

View File

@@ -4,6 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kipinä Agentic Playground</title> <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>
<style> <style>
:root { :root {
--bg-color: #0d1117; --bg-color: #0d1117;
@@ -134,15 +136,12 @@
padding: 14px; padding: 14px;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
color: var(--success-color);
white-space: pre-wrap; white-space: pre-wrap;
overflow-x: auto; overflow-x: auto;
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
} }
.code-output .keyword { color: #ff7b72; } .code-output .hljs { background: transparent; padding: 0; }
.code-output .string { color: #a5d6ff; }
.code-output .comment { color: #8b949e; }
.code-task-card { .code-task-card {
background: #0d1117; background: #0d1117;
@@ -1090,14 +1089,27 @@
</div> </div>
</div> </div>
<div class="terminal-panel" id="agent-terminal" style="margin-top: 20px;"> <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">
<div class="terminal-line"><span class="terminal-prompt">$</span> kpn hub connect wss://localhost</div> <span style="display:flex;align-items:center;gap:6px" title="Hub-yhteyden tila">
<div class="terminal-line" style="color:#a5d6ff"> ✓ Yhdistetty Kipinä Hubiin</div> <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>
<div style="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"> <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> <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" <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"> 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> </div>
</div><!-- /panel-agents --> </div><!-- /panel-agents -->
@@ -1446,6 +1458,12 @@
selected_task: viewTask, 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 // URL-hash navigointi
@@ -1473,12 +1491,19 @@
let detectedWebGPU = false; let detectedWebGPU = false;
let detectedGpuInfo = null; let detectedGpuInfo = null;
let wasmInitialized = false; let wasmInitialized = false;
let coderSize = '05b'; // '05b' tai '3b' let coderSize = localStorage.getItem('kpn-coder-size') || '05b';
// Mallivalinnan radio-napit // 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 => { document.querySelectorAll('input[name="coder-size"]').forEach(radio => {
radio.addEventListener('change', (e) => { radio.addEventListener('change', (e) => {
coderSize = e.target.value; coderSize = e.target.value;
localStorage.setItem('kpn-coder-size', coderSize);
// Visuaalinen korostus // Visuaalinen korostus
document.getElementById('coder-opt-05b').style.borderColor = coderSize === '05b' ? 'var(--accent-color)' : 'var(--border-color)'; 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)'; document.getElementById('coder-opt-3b').style.borderColor = coderSize === '3b' ? 'var(--accent-color)' : 'var(--border-color)';
@@ -1634,6 +1659,14 @@
const uiSocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`); const uiSocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`);
window._uiSocket = uiSocket; window._uiSocket = uiSocket;
uiSocket.onopen = async () => { 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';
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 // Päivitetään molemmat statukset
const el = document.getElementById('node-status'); const el = document.getElementById('node-status');
el.textContent = 'Connected'; el.textContent = 'Connected';
@@ -1681,6 +1714,13 @@
})); }));
}; };
uiSocket.onclose = () => { 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'); const el = document.getElementById('node-status');
el.textContent = 'Disconnected'; el.textContent = 'Disconnected';
el.style.color = '#f85149'; el.style.color = '#f85149';
@@ -1748,7 +1788,8 @@
const tokGen = data.tokens_generated || 0; 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(` <span style="color:#3fb950">✓</span> <span style="color:#58a6ff">${data.model || model}</span> <span style="color:#8b949e">(${tokGen} tok)</span>`);
if (!silent) { if (!silent) {
termLog(` ${esc(response).replace(/\n/g,'\n ')}`, '#c9d1d9'); const highlighted = highlightCode(response).replace(/\n/g, '\n ');
termLog(` <pre style="margin:0;font:inherit;white-space:pre-wrap">${highlighted}</pre>`);
} }
return response; return response;
} catch (e) { } catch (e) {
@@ -1802,6 +1843,7 @@
termLog(' kpn hello — iloinen tervehdys verkosta', '#a5d6ff'); termLog(' kpn hello — iloinen tervehdys verkosta', '#a5d6ff');
termLog(' kpn run &lt;malli&gt; "&lt;prompti&gt;" — aja tehtävä verkossa', '#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 pipeline "&lt;tehtävä&gt;" — manageri → koodari → testaaja', '#a5d6ff');
termLog(' kpn load — lataa kielimalli omalle koneelle', '#a5d6ff');
termLog(' kpn status — verkon tila', '#a5d6ff'); termLog(' kpn status — verkon tila', '#a5d6ff');
termLog(' kpn models — käytettävissä olevat mallit', '#a5d6ff'); termLog(' kpn models — käytettävissä olevat mallit', '#a5d6ff');
termLog(' kpn clear — tyhjennä terminaali', '#a5d6ff'); termLog(' kpn clear — tyhjennä terminaali', '#a5d6ff');
@@ -1813,6 +1855,41 @@
return; return;
} }
if (sub === 'load') {
const arg = parts[2];
const btn = document.getElementById('agent-compute-btn');
// Mallikatalogista valinta numerolla tai nimellä
const loadModels = [
{ id: '1', key: '05b', name: 'Qwen2.5-Coder:0.5B', size: '~990 MB', coderSize: '05b' },
{ id: '2', key: '3b', name: 'Qwen2.5-Coder:3B', size: '~6.2 GB', coderSize: '3b' },
];
if (!arg) {
// Näytetään lista
termLog(' Ladattavat mallit:', '#c9d1d9');
for (const m of loadModels) {
const active = (btn?.dataset.state === 'ready' && coderSize === m.coderSize) ? ' <span style="color:#3fb950">✓ ladattu</span>' : '';
termLog(` <span style="color:#58a6ff">${m.id}</span> ${m.name} <span style="color:#8b949e">(${m.size})</span>${active}`);
}
termLog(' Käyttö: kpn load &lt;numero&gt;', '#8b949e');
return;
}
const selected = loadModels.find(m => m.id === arg || m.key === arg || m.coderSize === arg);
if (!selected) {
termLog(` Tuntematon malli "${esc(arg)}". Kokeile: kpn load`, '#f85149');
return;
}
if (btn?.dataset.state === 'ready' && coderSize === selected.coderSize) {
termLog(`${selected.name} on jo ladattu ja valmis`, '#3fb950');
return;
}
coderSize = selected.coderSize;
localStorage.setItem('kpn-coder-size', coderSize);
termLog(` Alustetaan ${selected.name} (${selected.size})...`, '#d29922');
if (btn) btn.click();
else ensureCoderNode();
return;
}
if (sub === 'status') { if (sub === 'status') {
const nodes = statNodes.textContent || '0'; const nodes = statNodes.textContent || '0';
const vram = statVram.textContent || '?'; const vram = statVram.textContent || '?';
@@ -1821,11 +1898,14 @@
} }
if (sub === 'models') { if (sub === 'models') {
termLog(' smollm-135m — SmolLM 135M (kevyt)', '#a5d6ff'); termLog(' Käytettävissä olevat mallit:', '#c9d1d9');
termLog(' qwen-05b Qwen2.5 0.5B', '#a5d6ff'); termLog(' <span style="color:#58a6ff">1</span> qwen-coder Qwen2.5-Coder:0.5B <span style="color:#8b949e">~990 MB | koodin generointi</span>');
termLog(' phi3-mini — Phi-3 Mini', '#a5d6ff'); termLog(' <span style="color:#58a6ff">2</span> qwen-coder-3b Qwen2.5-Coder:3B <span style="color:#8b949e">~6.2 GB | parempi koodinlaatu</span>');
termLog(' qwen-coder — Qwen2.5-Coder 0.5B', '#a5d6ff'); termLog(' <span style="color:#58a6ff">3</span> smollm-135m SmolLM 135M <span style="color:#8b949e">~270 MB | kevyt, nopea</span>');
termLog(' qwen-coder-3b — Qwen2.5-Coder 3B', '#a5d6ff'); termLog(' <span style="color:#58a6ff">4</span> qwen-05b Qwen2.5:0.5B <span style="color:#8b949e">~990 MB | yleismalli</span>');
termLog(' <span style="color:#58a6ff">5</span> phi3-mini Phi-3 Mini <span style="color:#8b949e">~2.2 GB | Microsoftin malli</span>');
termLog(' Käyttö: kpn run &lt;malli&gt; "&lt;prompti&gt;"', '#8b949e');
termLog(' Lataus: kpn load &lt;numero&gt;', '#8b949e');
return; return;
} }
@@ -1858,7 +1938,9 @@
} }
// Jos käyttäjä syötti agentin nimen (esim. "coder"), vaihdetaan se oikeaksi tekoälymalliksi ("qwen-coder") // Jos käyttäjä syötti agentin nimen (esim. "coder"), vaihdetaan se oikeaksi tekoälymalliksi ("qwen-coder")
if (agentPrompts[model]) { if (model === 'coder-3b') {
model = 'qwen-coder-3b';
} else if (agentPrompts[model]) {
model = agentPrompts[model].model; model = agentPrompts[model].model;
} }
@@ -1869,18 +1951,203 @@
termLog(` kpn: tuntematon alikomento "${sub}". Kokeile: kpn help`, '#f85149'); termLog(` kpn: tuntematon alikomento "${sub}". Kokeile: kpn help`, '#f85149');
} }
// Tab-completion: ennustava komennonsyöttö sana kerrallaan
const kpnCommands = {
'kpn': ['help', 'run', '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 pipeline': ['"rakenna todo-sovellus"', '"tee laskin pythonilla"'],
};
function tabComplete(input) {
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) => { termInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { // 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();
// Näytä dropdown tai täydennä jos vain yksi vaihtoehto
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(); const cmd = termInput.value.trim();
if (cmd) termExec(cmd); if (cmd) termExec(cmd);
termInput.value = ''; termInput.value = '';
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp' && !dropdown?.style.display?.includes('block')) {
e.preventDefault(); e.preventDefault();
if (termHistIdx < termHistory.length - 1) { if (termHistIdx < termHistory.length - 1) {
termHistIdx++; termHistIdx++;
termInput.value = termHistory[termHistIdx]; termInput.value = termHistory[termHistIdx];
} }
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown' && !dropdown?.style.display?.includes('block')) {
e.preventDefault(); e.preventDefault();
if (termHistIdx > 0) { if (termHistIdx > 0) {
termHistIdx--; termHistIdx--;
@@ -1892,6 +2159,11 @@
} }
}); });
// Suljetaan dropdown kun klikataan muualle
document.addEventListener('click', (e) => {
if (!termInput?.contains(e.target) && !dropdown?.contains(e.target)) hideDropdown();
});
// Klikkaa terminaalipaneelia → fokusoi input // Klikkaa terminaalipaneelia → fokusoi input
termPanel?.addEventListener('click', () => termInput?.focus()); termPanel?.addEventListener('click', () => termInput?.focus());
@@ -2099,7 +2371,7 @@
Prompt: <span style="color:#d29922">"${esc(stripSystemPrompt(data.prompt))}"</span> Prompt: <span style="color:#d29922">"${esc(stripSystemPrompt(data.prompt))}"</span>
</div> </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' : ''}"> <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 ? esc(data.response) : '<em>tyhjä vastaus</em>'} ${data.response ? highlightCode(data.response) : '<em>tyhjä vastaus</em>'}
</div> </div>
<div style="margin-top:8px;font-size:12px;color:#8b949e"> <div style="margin-top:8px;font-size:12px;color:#8b949e">
${tokGen} tokenia generoitu | malli ladattu: ${typeof loadMs === 'number' ? loadMs.toFixed(0) : loadMs}ms ${tokGen} tokenia generoitu | malli ladattu: ${typeof loadMs === 'number' ? loadMs.toFixed(0) : loadMs}ms
@@ -2182,8 +2454,37 @@
targetBox.scrollTop = targetBox.scrollHeight; targetBox.scrollTop = targetBox.scrollHeight;
} }
} }
} else if (data.type === "task_routed") {
const term = document.getElementById('agent-terminal');
const isQueued = data.status === 'queued';
const color = isQueued ? '#d29922' : '#58a6ff';
const icon = isQueued ? '⏳' : '→';
const msg = esc(data.message || '');
// Agents-terminaali
if (term && data.task_id && activeStreams[data.task_id]) {
const div = document.createElement('div');
div.className = 'terminal-line';
div.style.color = color;
div.innerHTML = ` ${icon} ${msg}`;
if (isQueued) div.id = 'routing-' + data.task_id;
// Päivitetään olemassaoleva jonorivi jos löytyy
const existing = document.getElementById('routing-' + data.task_id);
if (existing) { existing.innerHTML = ` ${icon} ${msg}`; existing.style.color = color; }
else term.appendChild(div);
term.scrollTop = term.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") { } else if (data.type === "llm_prompt") {
if (data.task_id) { // Reagoidaan VAIN agents-pipelinen tehtäviin (task_id + activeStreams)
if (data.task_id && activeStreams[data.task_id]) {
const term = document.getElementById('agent-terminal'); const term = document.getElementById('agent-terminal');
if (term) { if (term) {
const model = data.model || 'llm'; const model = data.model || 'llm';
@@ -2195,26 +2496,26 @@
while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild); while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
term.scrollTop = term.scrollHeight; term.scrollTop = term.scrollHeight;
} }
}
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
const model = data.model || '';
const p = data.prompt ? data.prompt.toLowerCase() : '';
if (p.includes('tiiminvetäjä') || p.includes('pilko')) { // Avatar-aktivointi vain omille tehtäville
document.getElementById('avatar-kpn')?.classList.add('active'); document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
} else if (p.includes('arvioi seuraava koodi') || p.includes('ohjelmiston julkaisu')) { const model = data.model || '';
document.getElementById('avatar-tester')?.classList.add('active'); const p = data.prompt ? data.prompt.toLowerCase() : '';
} else if (p.includes('tervehdi')) {
document.getElementById('avatar-client')?.classList.add('active'); if (p.includes('tiiminvetäjä') || p.includes('pilko')) {
} else if (p.includes('test')) { document.getElementById('avatar-kpn')?.classList.add('active');
document.getElementById('avatar-qa')?.classList.add('active'); } else if (p.includes('arvioi seuraava koodi') || p.includes('ohjelmiston julkaisu')) {
} else if (model.includes('coder') || model.includes('Coder')) { document.getElementById('avatar-tester')?.classList.add('active');
// Koodari aktivoituu, jos kyse on suoraan koodarille osoitetusta mallitehtävästä (esim. network task) } else if (p.includes('tervehdi')) {
document.getElementById('avatar-coder')?.classList.add('active'); document.getElementById('avatar-client')?.classList.add('active');
} else if (model.includes('deepseek') || model.includes('r1')) { } else if (p.includes('test')) {
document.getElementById('avatar-observer')?.classList.add('active'); document.getElementById('avatar-qa')?.classList.add('active');
} else if (model.includes('coder') || model.includes('Coder')) {
document.getElementById('avatar-coder')?.classList.add('active');
} else if (model.includes('deepseek') || model.includes('r1')) {
document.getElementById('avatar-observer')?.classList.add('active');
}
} }
// Emme enää aseta oletusagenttia, jottei tuntemattomissa verkkopyynnöissä mikään turhaan hypi silmille.
} }
} catch(e) {} } catch(e) {}
}; };
@@ -2353,20 +2654,14 @@
let pendingCodePrompt = null; let pendingCodePrompt = null;
// Yksinkertainen Python-syntaksikorostus // Yksinkertainen Python-syntaksikorostus
function highlightPython(code) { function highlightCode(code) {
return code if (typeof hljs !== 'undefined') {
// Kommentit try {
.replace(/(#.*)/g, '<span style="color:#8b949e">$1</span>') const result = hljs.highlightAuto(code);
// Merkkijonot (f-stringit, tavalliset) return result.value;
.replace(/(f?"[^"]*"|f?'[^']*')/g, '<span style="color:#a5d6ff">$1</span>') } catch(e) {}
// Avainsanat }
.replace(/\b(def|return|if|elif|else|for|while|in|not|and|or|is|import|from|class|try|except|with|as|lambda|yield|True|False|None|raise|pass|break|continue)\b/g, '<span style="color:#ff7b72">$1</span>') return esc(code);
// Sisäänrakennetut funktiot
.replace(/\b(print|len|range|int|str|float|list|dict|set|tuple|type|isinstance|enumerate|zip|map|filter|sorted|reversed|sum|min|max|abs|round|input|open)\b/g, '<span style="color:#d2a8ff">$1</span>')
// Numerot
.replace(/\b(\d+\.?\d*)\b/g, '<span style="color:#79c0ff">$1</span>')
// Dekoraattorit
.replace(/(@\w+)/g, '<span style="color:#d2a8ff">$1</span>');
} }
function addCodeResult(data) { function addCodeResult(data) {
@@ -2399,7 +2694,7 @@
card.className = 'code-task-card'; card.className = 'code-task-card';
card.innerHTML = ` card.innerHTML = `
<div class="prompt">${esc(stripSystemPrompt(data.prompt))}</div> <div class="prompt">${esc(stripSystemPrompt(data.prompt))}</div>
<div class="code-output">${highlightPython(response)}</div> <div class="code-output">${highlightCode(response)}</div>
<div class="meta"> <div class="meta">
${model} · ${tokGen} tokenia · ${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms · ${tokS} tok/s ${model} · ${tokGen} tokenia · ${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms · ${tokS} tok/s
</div>`; </div>`;
@@ -2438,12 +2733,14 @@
const origCodeLog = console.log; const origCodeLog = console.log;
const codeLogListener = (...args) => { const codeLogListener = (...args) => {
const msg = args.join(' '); const msg = args.join(' ');
if (msg.includes('[Coder]') || msg.includes('Burn Wasm') || msg.includes('Kipinä Agent Node')) { if (msg.includes('[Coder]') || msg.includes('[Storage]') || msg.includes('Burn Wasm') || msg.includes('Kipinä Agent Node')) {
if (msg.includes('Burn Wasm')) setStep('step-wasm', 'active'); if (msg.includes('Burn Wasm')) setStep('step-wasm', 'active');
if (msg.includes('Agent Node käynnistyy')) { setStep('step-wasm', 'done'); } if (msg.includes('Agent Node käynnistyy')) { setStep('step-wasm', 'done'); }
if (msg.includes('[Coder]') && msg.includes('tokenizer') && msg.includes('löytyi')) { setStep('step-tokenizer', 'done'); } // Tokenizer: [Coder] tai [Storage] -prefiksi
if (msg.includes('[Coder]') && msg.includes('Ladataan') && msg.includes('tokenizer')) { setStep('step-tokenizer', 'active'); } if (msg.includes('Tokenizer') && msg.includes('löytyi')) { setStep('step-tokenizer', 'done'); }
if (msg.includes('[Coder]') && msg.includes('tokenizer') && msg.includes('tallennettu')) { 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:')) { if (msg.includes('[Coder]') && msg.includes('model') && msg.includes('lataus:')) {
setStep('step-model', 'active'); setStep('step-model', 'active');
const match = msg.match(/lataus: (\d+)%/); const match = msg.match(/lataus: (\d+)%/);
@@ -2452,6 +2749,19 @@
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('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('model') && msg.includes('tallennettu')) { setStep('step-model', 'done', '100%'); }
if (msg.includes('[Coder]') && msg.includes('Rakennetaan')) { setStep('step-build', 'active'); } 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')) { if (msg.includes('[Coder]') && msg.includes('Malli ladattu')) {
// Malli on valmis — merkataan kaikki vaiheet valmiiksi // Malli on valmis — merkataan kaikki vaiheet valmiiksi
setStep('step-wasm', 'done'); setStep('step-wasm', 'done');
@@ -2466,6 +2776,15 @@
setStep('step-build', 'done'); setStep('step-build', 'done');
setStep('step-ready', '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';
if (cl) { cl.textContent = 'Qwen2.5-Coder'; cl.style.color = '#3fb950'; }
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');
} }
if (msg.includes('[Coder]') && msg.includes('Syöte:')) { if (msg.includes('[Coder]') && msg.includes('Syöte:')) {
// Pipeline piiloon kun generointi alkaa // Pipeline piiloon kun generointi alkaa
@@ -2525,10 +2844,23 @@
document.getElementById('coder-status').style.color = '#d29922'; document.getElementById('coder-status').style.color = '#d29922';
coderWsReady = true; coderWsReady = true;
// Proaktiivinen mallin esilataus: lähetetään tyhjä warmup-prompt
// joka triggeröi get_or_build_model:n ilman varsinaista generointia.
// Pipeline-tilakone seuraa logeja ja merkkaa vaiheet valmiiksi.
setTimeout(() => {
if (uiSocket && uiSocket.readyState === 1) {
uiSocket.send(JSON.stringify({
type: 'user_text',
text: '{"prompt":"warmup","max_tokens":1}',
task_type: 'qwen-coder'
}));
}
}, 500);
if (pendingCodePrompt) { if (pendingCodePrompt) {
setTimeout(() => { setTimeout(() => {
sendCodeToHub(pendingCodePrompt); sendCodeToHub(pendingCodePrompt);
}, 800); }, 2000); // Hieman pidempi odotus jotta warmup ehtii ensin
pendingCodePrompt = null; pendingCodePrompt = null;
} }
} catch(e) { } catch(e) {
@@ -2539,6 +2871,38 @@
} }
} }
// Automaattinen uudelleenkäynnistys: jos malli oli ladattu ennen refreshiä, ladataan se uudelleen cachesta
if (localStorage.getItem('kpn-coder-loaded') === 'true') {
// Pieni viive jotta UI ehtii piirtyä
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 // JSON mode toggle
const jsonToggle = document.getElementById('json-mode-toggle'); const jsonToggle = document.getElementById('json-mode-toggle');
const jsonHelp = document.getElementById('json-help'); const jsonHelp = document.getElementById('json-help');