Compare commits
54 Commits
02f6684378
...
agentic-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac15336c9f | ||
|
|
7a15cacebf | ||
|
|
27135a8f14 | ||
|
|
e28a715f32 | ||
|
|
24d29d9ba9 | ||
|
|
7eca426e77 | ||
|
|
7a1352ead7 | ||
|
|
b9017448d8 | ||
|
|
3d1b406e8d | ||
|
|
aa6c4739dd | ||
|
|
cbbf427a93 | ||
|
|
0a216f19e2 | ||
|
|
a2e7ed53ff | ||
|
|
950cae9d96 | ||
|
|
ff3a720b8d | ||
|
|
6f14614af8 | ||
|
|
518c6dc5cb | ||
|
|
b48eeb6f5f | ||
|
|
6bc7d03676 | ||
|
|
13b2911d38 | ||
|
|
38054452e2 | ||
|
|
50ff34cb09 | ||
|
|
949f34833f | ||
|
|
88fd31ca8c | ||
|
|
57c6506f91 | ||
|
|
12fd3c4eae | ||
|
|
1845ddf8c2 | ||
|
|
0ef1a3f7cd | ||
|
|
4e49cfbbfa | ||
|
|
133ff38fa4 | ||
|
|
3ada8949d0 | ||
| 2e7ddf6f1e | |||
| f266c121c2 | |||
| f83ea9d90a | |||
| 2ea24c6248 | |||
| 2d065ee0bf | |||
| 3d2b2342e0 | |||
| 2a242efbd8 | |||
| f687351f69 | |||
| 0d3e0ff86a | |||
| 057d464fdd | |||
| 185a40dbdf | |||
| 2ad20bdc62 | |||
| 31995fb278 | |||
| 6cdd695a3b | |||
| 92c952c07a | |||
| e1326b145e | |||
| d2920e5ab4 | |||
| 8e20b06344 | |||
| 11bd802be5 | |||
| b693542116 | |||
| e55ff64565 | |||
| f5e4883915 | |||
| 9a72d35081 |
1
.gitignore
vendored
@@ -21,6 +21,7 @@
|
|||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
|
target-native/
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
|
|||||||
42
TODO.md
@@ -1 +1,41 @@
|
|||||||
Lisää viesteihin tietoturvallinen kryptaus - mitään selkokielistä ei ole hyvä lähettää.
|
# Kipinä Agentic Network: TODO-lista
|
||||||
|
|
||||||
|
- [x] **Tietoturva & yksityisyys:** Lisää viesteihin tietoturvallinen kryptaus (E2E-salaus / Blind Orchestrator). Mitään selkokielistä ei ole hyvä lähettää vieraalle solmulle.
|
||||||
|
- [x] **Reititysarkkitehtuuri:** Hubin kohdennettu reititys. Broadcastin sijaan tehtävät ohjataan vain parhaalle vapana olevalle solmulle (Node Registry & Matchmaking) tehtävän tyypin ja resurssien perusteella.
|
||||||
|
- [x] **P2P-jakelu:** WebRTC Data Channels mallipainojen jakamiseen suoraan solmujen välillä kaistan ja latausaikojen säästämiseksi.
|
||||||
|
- [x] **Tulosten varmentaminen:** Proof of Compute / Konsensus-mekanismi, jossa sama tehtävä annetaan kahdelle solmulle, ja tila hyväksytään vasta kun ristiintarkastus täsmää.
|
||||||
|
- [x] **Optimaalinen laitekiihdytys:** Selainpuolen laajennus tulevaa WebNN-standardia (NPU API) varten WebGPU:n rinnalle.
|
||||||
|
- [x] **Insentiivit:** Gamifikaatio, pistetaulukko tai token-talous (esim. Kipinä Tokens), joka motivoi käyttäjiä tarjoamaan laitteensa laskentatehoa verkoston käyttöön pidemmäksi aikaa.
|
||||||
|
- [x] **Pelimerkkien UI-synkkaus:** Pelimerkkien saldon synkronointi reaaliajassa Hubista takaisin valikossa olevalle selainsolmulle ja luvun visuaalinen näyttäminen.
|
||||||
|
- [x] **XSS-suojaus:** HTML-escape kaikelle backend-datalle joka renderöidään DOM:iin (prompt, response, tokenisaatiotekstit).
|
||||||
|
- [x] **System prompt -vuoto:** Agents-pipelinen system prompt ei enää näy käyttäjälle vastauksissa.
|
||||||
|
- [x] **Token-saldon data race:** Korjattu atomiseksi operaatioksi.
|
||||||
|
- [x] **UTF-8 slicing panic:** Korjattu kaikki `&text[..n]` → `text.chars().take(n)`.
|
||||||
|
- [x] **Tensor dim unwrap:** Lisätty virheenkäsittely tyhjälle tensorille natiivisolmussa.
|
||||||
|
- [x] **llm_error-viestien tuki:** Lisätty hubiin ja frontendiin, streaming-kortti siivoutuu virhetilanteessa.
|
||||||
|
- [x] **Malli-cache (selain):** QwenModel pidetään muistissa `thread_local! MODEL_CACHE`:ssa, `clear_kv_cache()` promptien välillä.
|
||||||
|
- [x] **Malli-cache (natiivi):** `LlmEngine` pitää mallin muistissa, `fresh_model()` poistettu.
|
||||||
|
- [x] **Sampling:** Greedy argmax korvattu temperature + top-k + repetition penalty -samplingillä (sekä selain että natiivi).
|
||||||
|
- [x] **Stop-sekvenssit:** Generointi katkaistaan kun malli alkaa tuottaa selityksiä.
|
||||||
|
- [x] **Codelab/Agents-reititys:** `llm_done` ja `llm_chunk` reitittyy `task_id`:n perusteella oikeaan näkymään.
|
||||||
|
- [x] **Broadcast Lag:** `RecvError::Lagged` käsitellään gracefully sekä sender-taskissa että API-endpointissa — solmu ei enää tipu verkosta.
|
||||||
|
- [x] **Busy-tila reititys:** Hub seuraa solmujen busy-tilaa (`node_busy`). Tehtäviä ei enää reititetä varatuille solmuille.
|
||||||
|
- [x] **Rate limiting:** `/api/v1/chat/completions` rajoittaa max 10 pyyntöä/minuutti per IP.
|
||||||
|
- [x] **Gamification-validointi:** Kipinä-merkkejä jaetaan vain tehtävistä joiden `task_id` on hubin jakama (`pending_task_ids`).
|
||||||
|
- [x] **Base64:** Oma base64-dekooderi korvattu `base64`-cratella.
|
||||||
|
- [x] **Atominen siivous:** Solmun disconnect-siivouksessa kaikki lukot otetaan kerralla.
|
||||||
|
- [x] **DOM-vuoto:** Terminaalin trim ei enää poista aktiivista streaming-riviä.
|
||||||
|
|
||||||
|
## Havaitut Bugaavat Ominaisuudet ja Arkkitehtuuriongelmat
|
||||||
|
|
||||||
|
### Keskitaso (eivät estä käyttöä)
|
||||||
|
|
||||||
|
- [ ] **Origin-headerin validoinnin ohitus:** Natiivisolmut eivät lähetä Origin-headeria, joten tarkistus ohitetaan. Hyökkääjä voi esiintyä natiivisolmuna. Korjaus: vaadi autentikaatio natiivisolmuilta (API-avain tai token).
|
||||||
|
- [ ] **Kovakoodattu oletussalasana:** Admin-paneelin oletussalasana on `"kipina"` jos `ADMIN_PASSWORD`-ympäristömuuttujaa ei aseta. Tuotannossa pitää asettaa pakollisesti. Varoitus logitetaan.
|
||||||
|
|
||||||
|
### Arkkitehtuuriparannukset (tulevaisuus)
|
||||||
|
|
||||||
|
- [ ] **E2E-salaus:** Promptit ja vastaukset kulkevat selkokielisinä WebSocketin yli. Placeholder-kommentti koodissa, mutta ei toteutusta.
|
||||||
|
- [ ] **Proof of Work / konsensus:** Solmu voi lähettää väärennettyjä tuloksia. Merkitty TODO:ksi, mutta ei toteutusta.
|
||||||
|
- [ ] **WebGPU-inferenssi Candle-mallille:** Selainsolmu käyttää aina CPU:ta Candle-inferenssiin. Candle ei vielä tue WebGPU:ta.
|
||||||
|
- [ ] **Streaming yield -optimointi:** Pitkillä generoinneilla (>128 tok) selaimen event loop voi jäätyä hetkeksi koska generointilooppi ajetaan synkronisessa closuressa. Korjaus: pilko generointilooppi eriin ja yield joka N:s token.
|
||||||
|
|||||||
525
network-poc/BUILDING_BLOCKS.md
Normal 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,'&').replace(/</g,'<')
|
||||||
|
.replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
@@ -4,4 +4,4 @@ members = [
|
|||||||
"hub",
|
"hub",
|
||||||
"node",
|
"node",
|
||||||
"native-node"
|
"native-node"
|
||||||
]
|
, "cli"]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM rust:slim AS builder
|
FROM rust:slim AS builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
@@ -8,40 +9,36 @@ RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 1. Kopioi vain Cargo-tiedostot → riippuvuudet cacheen
|
# Kopioi kaikki Cargo-tiedostot
|
||||||
COPY Cargo.toml ./
|
COPY Cargo.toml ./
|
||||||
COPY Cargo.lock* ./
|
COPY Cargo.lock* ./
|
||||||
COPY hub/Cargo.toml hub/Cargo.toml
|
COPY hub/Cargo.toml hub/Cargo.toml
|
||||||
COPY node/Cargo.toml node/Cargo.toml
|
COPY node/Cargo.toml node/Cargo.toml
|
||||||
COPY native-node/Cargo.toml native-node/Cargo.toml
|
COPY native-node/Cargo.toml native-node/Cargo.toml
|
||||||
|
COPY cli/Cargo.toml cli/Cargo.toml
|
||||||
|
|
||||||
# Tyhjät lähteet riippuvuuksien esikääntämistä varten
|
# Kopioi lähdekoodi
|
||||||
RUN mkdir -p hub/src node/src native-node/src \
|
|
||||||
&& echo "fn main(){}" > hub/src/main.rs \
|
|
||||||
&& echo "" > node/src/lib.rs \
|
|
||||||
&& mkdir -p node/src && touch node/src/storage.rs \
|
|
||||||
&& echo "fn main(){}" > native-node/src/main.rs \
|
|
||||||
&& cargo build --release -p hub 2>/dev/null || true \
|
|
||||||
&& wasm-pack build node --target web --out-dir ../static/pkg 2>/dev/null || true
|
|
||||||
|
|
||||||
# 2. Kopioi oikea lähdekoodi → vain src käännetään uudelleen
|
|
||||||
COPY hub/src hub/src
|
COPY hub/src hub/src
|
||||||
COPY node/src node/src
|
COPY node/src node/src
|
||||||
|
COPY native-node/src native-node/src
|
||||||
|
COPY cli/src cli/src
|
||||||
COPY static static
|
COPY static static
|
||||||
|
|
||||||
# Pakota uudelleenkäännös
|
# Rakenna Wasm — cache mount pitää Cargo-rekisterin ja target-kansion buildien välillä
|
||||||
RUN touch hub/src/main.rs node/src/lib.rs
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/app/target \
|
||||||
|
cd node && wasm-pack build --target web --out-dir ../static/pkg
|
||||||
|
|
||||||
# Rakenna Wasm-paketti
|
# Rakenna Hub
|
||||||
RUN cd node && wasm-pack build --target web --out-dir ../static/pkg
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/app/target \
|
||||||
# Rakenna Hub release-binääri
|
cargo build --release -p hub \
|
||||||
RUN cargo build --release -p hub
|
&& cp /app/target/release/hub /usr/local/bin/hub
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/hub /usr/local/bin/hub
|
COPY --from=builder /usr/local/bin/hub /usr/local/bin/hub
|
||||||
COPY --from=builder /app/static /app/static
|
COPY --from=builder /app/static /app/static
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,75 +1,134 @@
|
|||||||
# Kipinä Agentic Network PoC (WebGPU Edition)
|
# Kipinä Agentic Network PoC
|
||||||
|
|
||||||
Tämä on hajautetun tekoälylaskennan (Agentic Compute) kokeilulaboratorio. Projekti koostuu Rust-pohjaisesta keskuksesta (Hub) ja selainpohjaisista työntekijöistä (Nodet), jotka suorittavat tekoälytensoreiden matriisilaskentaa **WebGPU**-rajapintaa ja **Burn AI** -koneoppimiskirjastoa hyödyntäen.
|
Hajautettu AI-laskentaverkko selaimessa ja natiivina. Käyttäjät tarjoavat GPU/CPU-laskentatehoa avaamalla verkkosivun tai ajamalla natiivi-noden.
|
||||||
|
|
||||||
Normaalin keskitetyn palvelimen sijaan tämä kokeilu hyödyntää selaimeen kytkettyjen lukemattomien laitteiden vapaana olevaa tehokapasiteettia hajautetusti P2P-tyylillä.
|
**Tuotanto:** https://kipina.studio | **Admin:** https://kipina.studio/admin
|
||||||
|
|
||||||
## Kuinka käynnistää projekti paikallisesti
|
## Arkkitehtuuri
|
||||||
|
|
||||||
1. **Rakenna solmun WebAssembly-binääri**
|
```
|
||||||
Paketoi Rust WebAssemblyksi (vaatii `wasm-pack`-työkalun):
|
┌─────────────────┐
|
||||||
```bash
|
│ Hub (Axum) │
|
||||||
cd node
|
│ :3000 / Caddy │
|
||||||
wasm-pack build --target web --out-dir ../static/pkg
|
│ SQLite, WS BC │
|
||||||
|
└────────┬────────┘
|
||||||
|
WebSocket │ WebSocket
|
||||||
|
┌────────────────────┼────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────────┐ ┌─────────────────┐
|
||||||
|
│ Selainsolmu │ │ Selainsolmu │ │ Native Node │
|
||||||
|
│ Wasm + Burn │ │ Wasm + Candle │ │ Rust + Candle │
|
||||||
|
│ WebGPU/NdArray │ │ SmolLM/Qwen │ │ CPU/CUDA │
|
||||||
|
└────────────────┘ └────────────────┘ └─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Käynnistä Hub-Keskuspalvelin**
|
**Hub** broadcastaa tehtäviä (tokenisointiparit, LLM-promptit) kaikille solmuille WebSocketin kautta. Solmut käsittelevät vain oman tehtävätyyppinsä mukaiset viestit.
|
||||||
```bash
|
|
||||||
cd hub
|
|
||||||
cargo run
|
|
||||||
```
|
|
||||||
Palvelin lähtee pyörimään ja tarjoamaan sekä WebSocket-reititintä että staattista Dashboard-sivustoa lokaalisti portissa `3000`.
|
|
||||||
|
|
||||||
---
|
## Cratet
|
||||||
|
|
||||||
## ⚠️ WebGPU Ota-Käyttöön -ohjeet (Linux / Mac / Win)
|
| Crate | Polku | Kuvaus |
|
||||||
|
|---|---|---|
|
||||||
|
| `hub` | `hub/` | Axum WebSocket -palvelin, tehtävien jakelu, admin-API, SQLite |
|
||||||
|
| `node` | `node/` | Wasm-selainsolmu: Burn (tensorit), Candle (LLM), tokenizer |
|
||||||
|
| `native-node` | `native-node/` | Natiivi Rust-solmu: Candle LLM, NVML/wgpu GPU-tunnistus, sysinfo |
|
||||||
|
|
||||||
Selainvalmistajat rajoittavat tällä hetkellä uuden WebGPU-rajapinnan hardware-yhteyttä (fyysiseen näytönohjaimeen) turvallisuus- ja vakaussyistä, erityisesti Linuxin Wayland-ympäristöissä (kuten Pop!_OS, Ubuntu).
|
### Hub (`hub/src/`)
|
||||||
|
|
||||||
Päästäksesi hyödyntämään solmun laskentatehoa selaimesi ja tietokoneesi näytönohjaimen läpi, joudut todennäköisesti pakottamaan sen käyntiin.
|
- `main.rs` — WebSocket-reititin, tehtäväjakelu (10s intervalli), origin-tarkistus, IP-rajoitus, admin HTML
|
||||||
|
- `db.rs` — SQLite: `node_sessions` + `pair_results` taulut, skeemaversiointi
|
||||||
|
|
||||||
### Chromium-pohjaiset selaimet (Google Chrome, Brave, Chromium)
|
### Node (`node/src/`)
|
||||||
|
|
||||||
**Vaihtoehto 1: Käynnistys lipuilla (Suositeltu Linuxille ja Waylandille)**
|
- `lib.rs` — Wasm-entrypoint, tehtävävalinta (`SELECTED_TASK`), WebSocket-handler, GPU/CPU-valinta
|
||||||
Jos Chromesi tuottaa Wasm-kaatumisia tai väittää ettei adapteria löydy, laitteesi Wayland-palvelin estää Vulkan-rajapinnan oletuksena. Käynnistä selaimesi komentoriviltä pakottamalla vanha X11-ikkunointi ja Vulkan:
|
- `storage.rs` — IndexedDB read/write (tokenizer, mallin painot)
|
||||||
|
- `sampling.rs` — Top-k sampling EOS-penaltilla (kiertää Candlen softmax Wasm-bugin)
|
||||||
|
- `smollm.rs` — SmolLM 135M Candle-inferenssi (Llama-arkkitehtuuri)
|
||||||
|
- `qwen.rs` — Qwen2.5 0.5B Candle-inferenssi (Qwen2-arkkitehtuuri)
|
||||||
|
- `qwen_coder.rs` — Qwen2.5-Coder 0.5B/3B koodigenerointi (sama arkkitehtuuri, koodikoulutettu)
|
||||||
|
- `phi3.rs` — Phi-3 placeholder (liian iso selaimelle)
|
||||||
|
|
||||||
|
### Native Node (`native-node/src/`)
|
||||||
|
|
||||||
|
- `main.rs` — GPU-tunnistus (wgpu + NVML + sysfs + Apple), HF Hub -lataus, WS-yhteys
|
||||||
|
- `inference.rs` — Qwen2.5-0.5B Candle-inferenssi, CUDA/CPU, KV-cache reset per prompti, mmap-lataus
|
||||||
|
|
||||||
|
## Kehitysympäristö
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Google Chrome
|
# Vaatimukset
|
||||||
google-chrome --enable-unsafe-webgpu --enable-features=Vulkan --ignore-gpu-blocklist --use-angle=vulkan --ozone-platform=x11
|
rustup target add wasm32-unknown-unknown
|
||||||
|
cargo install wasm-pack
|
||||||
|
|
||||||
# Brave Browser
|
# Kehitys (Docker — Wasm buildataan automaattisesti)
|
||||||
brave-browser --enable-unsafe-webgpu --enable-features=Vulkan --ignore-gpu-blocklist --use-angle=vulkan --ozone-platform=x11
|
docker compose up
|
||||||
|
|
||||||
# Chromium
|
# Kehitys (ilman Dockeria)
|
||||||
chromium-browser --enable-unsafe-webgpu --enable-features=Vulkan --ignore-gpu-blocklist --use-angle=vulkan --ozone-platform=x11
|
cd node && wasm-pack build --dev --target web --out-dir ../static/pkg && cd ..
|
||||||
|
cargo run -p hub
|
||||||
|
# → http://localhost:3000
|
||||||
|
|
||||||
|
# Native node (erillinen terminaali)
|
||||||
|
CARGO_TARGET_DIR=target-native HUB_URL=ws://localhost:3000/ws cargo run --release -p native-node
|
||||||
```
|
```
|
||||||
|
|
||||||
*(Voit halutessasi testata puhdasta testi-ikkunaa erillisen profiilin kera, lisäämällä perään `--user-data-dir=/tmp/kipin-webgpu-test` jottei asetus sotke tai ohjaudu vanhaan auki olevaan sessioosi).*
|
## Viestityyypit (WebSocket JSON)
|
||||||
|
|
||||||
**Vaihtoehto 2: Sisäänrakennetun Flagin kääntö (Windows / Mac / Osittain Linux)**
|
Hub → solmut:
|
||||||
1. Kirjoita selaimen osoiteriville `chrome://flags` (tai `brave://flags`)
|
| Tyyppi | Kuvaus |
|
||||||
2. Etsi hakusanalla **WebGPU** (Unsafe WebGPU / WebGPU Developer Features) ja vaihda tilaksi `Enabled`
|
|---|---|
|
||||||
3. Etsi hakusanalla **Vulkan** ja vaihda tilaan `Enabled`
|
| `pair_task` | `{en, fi}` — tokenisointipari |
|
||||||
4. Uudelleenkäynnistä selain pienen napin kautta.
|
| `llm_prompt` | `{prompt, model}` — LLM-tehtävä |
|
||||||
|
| `stats` | `{nodes, vram_gb, tasks, version}` |
|
||||||
|
| `node_joined` | `{node_id}` |
|
||||||
|
|
||||||
---
|
Solmu → hub:
|
||||||
|
| Tyyppi | Kuvaus |
|
||||||
|
|---|---|
|
||||||
|
| `auth` | Laitetiedot, `selected_task`, `allocated_gb` |
|
||||||
|
| `pair_done` | Tokenisointitulos: `{en, fi, overhead_pct, duration_ms}` |
|
||||||
|
| `llm_done` | LLM-tulos: `{response, tokens_generated, tokens_per_sec}` |
|
||||||
|
| `llm_chunk` | Streaming-token |
|
||||||
|
| `download_progress` | Mallin latauksen edistyminen |
|
||||||
|
| `user_text` | Käyttäjän oma teksti: `{text, task_type}` |
|
||||||
|
|
||||||
### Mozilla Firefox
|
## API-endpointit
|
||||||
|
|
||||||
Firefox tukee WebGPU:ta toistaiseksi vahvasti vain Nightly-versioissa, mutta sitä voi yrittää aktivoida Config-asetuksista.
|
| Polku | Kuvaus |
|
||||||
1. Kirjoita osoiteriville `about:config` ja ymmärrä riskit.
|
|---|---|
|
||||||
2. Etsi `dom.webgpu.enabled` ja tuplaklikkaa arvoksi `true`.
|
| `GET /` | Dashboard (staattinen HTML) |
|
||||||
3. Etsi `gfx.webrender.all` ja aseta se `true`.
|
| `GET /ws` | WebSocket-yhteys |
|
||||||
4. Uudelleenkäynnistä Firefox.
|
| `GET /admin` | Admin-dashboard |
|
||||||
|
| `GET /api/sessions` | Node-sessiot (JSON) |
|
||||||
|
| `GET /api/pairs` | Tokenisointitulokset (JSON) |
|
||||||
|
| `GET /api/stats` | Yhteenvetotilastot (JSON) |
|
||||||
|
|
||||||
*(Huomio Linux-käyttäjille: Firefox saattaa edellyttää MOZ_ENABLE_WAYLAND ympäristömuuttujaa).*
|
## Tietoturva
|
||||||
|
|
||||||
---
|
- **Origin-tarkistus** — vain `https://kipina.studio` ja `localhost:3000`
|
||||||
|
- **IP-rajoitus** — max 4 WS-yhteyttä per IP, X-Forwarded-For -tuki
|
||||||
|
- **Viestivalidointi** — pakollinen `type`, sallitut tyypit, kenttäkohtaiset rajat
|
||||||
|
- **Viestikoko** — max 16 KB per WebSocket-viesti
|
||||||
|
- **Admin Basic Auth** — `/admin` ja `/api/*` salasanan takana (`ADMIN_PASSWORD` env, oletus: `kipina`)
|
||||||
|
- **Caddy** — automaattinen TLS (Let's Encrypt)
|
||||||
|
|
||||||
### Apple Safari (Mac)
|
## Tuotanto-deploy
|
||||||
|
|
||||||
Apple käyttää konepellin alla vahvaa omaa Metal-rajapintaansa ja tukee WebGPU:ta uudemmissa Safari-versioissa kehittäjäasetusten takaa:
|
```bash
|
||||||
1. Varmista ensin Safarin asetuksista (Preferences -> Advanced) , että ruutu on ruksittu kohdasta `"Show Develop menu in menu bar"`.
|
# Buildaa lokaalisti, siirrä palvelimelle, käynnistä
|
||||||
2. Valitse yläpalkista avautuva **Develop**-valikko -> **Feature Flags**.
|
./deploy.sh
|
||||||
3. Etsi listalta **WebGPU** ja laita siihen täppä pelastamaan tilanne.
|
|
||||||
4. Päivitä Dashboard-sivu.
|
# Manuaalisesti palvelimella
|
||||||
|
docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tiedossa olevat rajoitukset
|
||||||
|
|
||||||
|
- LLM-inferenssi käyttää **top-k samplingia** (k=10, EOS-penaltti) — ei täyttä temperature/top-p -tukea Wasmissa
|
||||||
|
- Qwen selaimessa: ~0.4 tok/s CPU — käyttökelpoinen demona mutta ei tuotantoon
|
||||||
|
- Native node + CUDA: ~50-100 tok/s (RTX 4090)
|
||||||
|
- Hub broadcastaa kaikki viestit kaikille — ei kohdennettu reititystä
|
||||||
|
- 3B Coder-malli vaatii ~12 GB RAM selaimessa (Wasm)
|
||||||
|
|
||||||
|
## Lisenssi
|
||||||
|
|
||||||
|
Kipinä Technologies Oy — sisäinen projekti.
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ Tässä on kooste projektin vaatimuksista, työtehtävistä ja niiden nykytilant
|
|||||||
- Sijoittaa Hub-palvelin julkisesti saatavuusosoitteeseen `kipina.studio`.
|
- Sijoittaa Hub-palvelin julkisesti saatavuusosoitteeseen `kipina.studio`.
|
||||||
|
|
||||||
### Tehtävät
|
### Tehtävät
|
||||||
- [ ] Tuotantopalvelimen käyttöönotto Nginxin tai Docker-compose kautta ehtojen täytyttyä
|
- [x] Tuotantopalvelimen käyttöönotto Docker-compose + Caddy TLS kautta (`kipina.studio`)
|
||||||
- [ ] Turvamekanismin lisäys: Varmistetaan, ettei kukaan lähetä "falskeja" vastauksia nodeilta
|
- [x] Deploy-skripti (`deploy.sh`) + Discord-webhook-notifikaatio julkaisuista
|
||||||
- [ ] Solmuille rekisteröitymismekanismi tai tulostaulukko
|
- [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.
|
- 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
|
### Tehtävät
|
||||||
- [ ] Refaktoroi Wasm-Noden (Burn.rs) paketti tuomaan Text-Tokenizerit (esim. BPE) ja kielimallin arkkitehtuuri käyttöön
|
- [x] Refaktoroi Wasm-Noden (Burn.rs) paketti tuomaan Text-Tokenizerit (BPE, Qwen2.5-Coder) ja kielimallin arkkitehtuuri käyttöön
|
||||||
- [ ] Koodaa Nodeen logiikka hakea / kasata mallin painot välimuistista "Chunk"-lohkoina valmiiksi
|
- [x] Koodaa Nodeen logiikka hakea / kasata mallin painot välimuistista IndexedDB:hen (tokenizer.json + model weights)
|
||||||
- [ ] Hub uudistetaan generoimaan pelkkien matikkavaikeuksien sijasta Text Prompts (esim. "Kirjoita haiku Suomesta") ja reitittämään työkuorman vapaalle solmulle
|
- [x] Hub uudistetaan generoimaan Text Prompts ja reitittämään työkuorman vapaalle solmulle (broadcast + task_id-matching)
|
||||||
- [ ] Kipinän käyttöliittymään Chat-ikkuna Hubin striimaamien tulossanojen tarkkailuun reaaliajassa
|
- [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
|
||||||
|
|||||||
@@ -15,20 +15,29 @@ Kipinä Agentic Network on hajautettu tekoälylaskentaverkko, jossa selaimet ja
|
|||||||
jos WebGPU ei tuettu
|
jos WebGPU ei tuettu
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hub** jakaa tokenisointitehtäviä satunnaisesti 10 sekunnin välein. Solmut tokenisoivat syötteen Qwen2.5-Coder-tokenizerin avulla ja palauttavat tuloksen. Hub näyttää tulokset terminaalissa ja välittää ne dashboardiin.
|
**Hub** jakaa tehtäviä (tokenisointiparit, LLM-promptit, kooditehtävät) 10 sekunnin välein. Solmut käsittelevät vain valitsemansa tehtävätyypin mukaisia viestejä.
|
||||||
|
|
||||||
## Kaksi tapaa osallistua verkkoon
|
## Kolme tapaa osallistua verkkoon
|
||||||
|
|
||||||
### 1. Selainsolmu (Wasm + WebGPU)
|
### 1. Selainsolmu — Laskentaverkko
|
||||||
- Avaa `http://localhost:3000` selaimessa ja klikkaa "Liity laskentaverkkoon"
|
- Avaa `http://localhost:3000` | `https://kipina.studio` ja valitse tehtävä:
|
||||||
- Selain tunnistaa automaattisesti WebGPU-tuen — jos ei löydy, käytetään CPU-fallbackia
|
- **Tokenisointivertailu** — EN/FI-kieliparien BPE-tokenisointitehokkuus (~7 MB lataus)
|
||||||
- Tokenizer ladataan HuggingFacesta ensimmäisellä kerralla ja tallennetaan IndexedDB:hen
|
- **SmolLM 135M** — kevyt LLM-inferenssi (~269 MB, ~1.2 tok/s)
|
||||||
- GPU-kuormitusta voi säätää sliderilla (0–75 %)
|
- **Qwen2.5 0.5B** — tehokkaampi LLM (~990 MB, ~0.4 tok/s)
|
||||||
|
- **Phi-3 Mini 3.8B** — vain native-nodella
|
||||||
|
- WebGPU tunnistetaan automaattisesti, CPU-fallback jos ei tuettu
|
||||||
|
- Mallit ja tokenizerit cachetetaan IndexedDB:hen
|
||||||
|
|
||||||
### 2. Natiivi-node (Rust + NVML)
|
### 2. Selainsolmu — Koodilaboratorio
|
||||||
|
- Erillinen välilehti: **Qwen2.5-Coder** koodigenerointi
|
||||||
|
- Valittavissa **0.5B** (nopea) tai **3B** (laadukas, 6.2 GB lataus)
|
||||||
|
- Oma promptti: kirjoita Python-ohjelmointitehtävä ja paina "Generate"
|
||||||
|
- Syntaksikorostettu koodivastaus
|
||||||
|
|
||||||
|
### 3. Natiivi-node (Rust + CUDA/CPU)
|
||||||
|
- Qwen2.5-0.5B-Instruct inferenssi CUDA:lla (~50-100 tok/s RTX 4090) tai CPU:lla (~11 tok/s)
|
||||||
- Kerää nvidia-smi-tason laitteistotiedot: GPU-nimi, VRAM, lämpötila, kuormitus
|
- Kerää nvidia-smi-tason laitteistotiedot: GPU-nimi, VRAM, lämpötila, kuormitus
|
||||||
- Raportoi järjestelmätiedot: CPU-malli, ytimet, RAM, OS
|
- Lataa mallin automaattisesti HuggingFace Hubista (~990 MB, cachetetaan)
|
||||||
- Yhdistää hubiin ja vastaanottaa tehtäviä
|
|
||||||
|
|
||||||
## Käynnistys
|
## Käynnistys
|
||||||
|
|
||||||
@@ -42,7 +51,7 @@ docker compose up
|
|||||||
docker compose --profile native up
|
docker compose --profile native up
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashboard avautuu osoitteessa http://localhost:3000
|
Dashboard avautuu osoitteessa http://localhost:3000 | https://kipina.studio
|
||||||
|
|
||||||
### Ilman Dockeria
|
### Ilman Dockeria
|
||||||
|
|
||||||
@@ -53,48 +62,83 @@ cd node && wasm-pack build --target web --out-dir ../static/pkg && cd ..
|
|||||||
# 2. Käynnistä hub (terminaali 1)
|
# 2. Käynnistä hub (terminaali 1)
|
||||||
cargo run -p hub
|
cargo run -p hub
|
||||||
|
|
||||||
# 3. Avaa selain: http://localhost:3000
|
# 3. Avaa selain: http://localhost:3000 | https://kipina.studio
|
||||||
|
|
||||||
# 4. Valinnainen: natiivi-node (terminaali 2)
|
# 4. Valinnainen: natiivi-node LLM-inferenssillä (terminaali 2)
|
||||||
HUB_URL=ws://localhost:3000/ws ALLOCATED_GB=4 cargo run -p native-node
|
# Lataa Qwen2.5-0.5B automaattisesti HuggingFacesta (~990 MB, cachetetaan)
|
||||||
|
# Release-moodissa ~11 tok/s CPU:lla (32 ydintä)
|
||||||
|
CARGO_TARGET_DIR=target-native HUB_URL=ws://localhost:3000/ws ALLOCATED_GB=4 cargo run --release -p native-node
|
||||||
|
|
||||||
|
|
||||||
|
# Tai yhdistä tuotantopalvelimeen:
|
||||||
|
CARGO_TARGET_DIR=target-native HUB_URL=wss://kipina.studio/ws ALLOCATED_GB=4 cargo run --release -p native-node
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebGPU-asetukset selaimessa
|
### CUDA-tuki
|
||||||
|
|
||||||
WebGPU ei ole oletuksena päällä kaikissa selaimissa. Jos "Liity laskentaverkkoon" -nappi käynnistää CPU-fallbackin vaikka koneessa on näytönohjain:
|
CUDA on oletuksena päällä native-nodessa. Vaatii `nvidia-cuda-toolkit`:n:
|
||||||
|
|
||||||
**Chrome / Brave (Linux + Wayland):**
|
|
||||||
```bash
|
```bash
|
||||||
google-chrome --enable-unsafe-webgpu --enable-features=Vulkan --ignore-gpu-blocklist --use-angle=vulkan --ozone-platform=x11
|
# Asenna (Ubuntu/Pop!_OS)
|
||||||
|
sudo apt install nvidia-cuda-toolkit
|
||||||
|
|
||||||
|
# Tarkista
|
||||||
|
nvcc --version
|
||||||
|
|
||||||
|
# Aja — tunnistaa CUDA:n automaattisesti, fallback CPU:lle
|
||||||
|
CARGO_TARGET_DIR=target-native HUB_URL=ws://localhost:3000/ws cargo run --release -p native-node
|
||||||
|
|
||||||
|
# Tuotantoon
|
||||||
|
CARGO_TARGET_DIR=target-native HUB_URL=wss://kipina.studio/ws cargo run --release -p native-node
|
||||||
```
|
```
|
||||||
|
|
||||||
**Chrome / Brave (Windows / Mac):**
|
Jos CUDA:a ei ole, poista feature: `candle-core = { version = "0.8" }` (ilman `features = ["cuda"]`).
|
||||||
1. Avaa `chrome://flags`
|
|
||||||
2. Ota käyttöön "WebGPU" ja "Vulkan"
|
|
||||||
3. Käynnistä selain uudelleen
|
|
||||||
|
|
||||||
**Firefox:** `about:config` → `dom.webgpu.enabled` = `true`
|
## Kuinka saat WebGPU:n aktivoitua selaimessasi:
|
||||||
|
|
||||||
**Safari:** Develop → Feature Flags → WebGPU
|
Jos käytät Chromea, Bravea tai Edgeä (Chromium-pohjainen):
|
||||||
|
|
||||||
|
- Kirjoita selaimen osoiteriville: `chrome://flags` (tai `brave://flags` / `edge://flags`)
|
||||||
|
- Etsi hakusanalla **WebGPU** tai **Unsafe WebGPU** (`#enable-unsafe-webgpu`).
|
||||||
|
- Vaihda asetus tilaan **Enabled**.
|
||||||
|
- *(Linuxilla erityisesti saatat joutua käynnistämään selaimen terminaalin kautta komennoilla `--enable-unsafe-webgpu --enable-features=Vulkan`, aivan kuten olit tehnyt tämän kehityssession alussa!)*
|
||||||
|
|
||||||
|
Jos käytät Firefoxia:
|
||||||
|
|
||||||
|
- Kirjoita osoiteriville: `about:config`
|
||||||
|
- Etsi `dom.webgpu.enabled` ja aseta se arvoon `true`.
|
||||||
|
- Etsi `gfx.webgpu.force-enabled` ja aseta se arvoon `true`.
|
||||||
|
|
||||||
## Projektin rakenne
|
## Projektin rakenne
|
||||||
|
|
||||||
```
|
```
|
||||||
network-poc/
|
network-poc/
|
||||||
├── hub/ # Keskuspalvelin (Rust + Axum)
|
├── hub/ # Keskuspalvelin (Rust + Axum)
|
||||||
│ └── src/main.rs # WebSocket-reititin, tehtävien jakelu, statistiikat
|
│ └── src/
|
||||||
|
│ ├── main.rs # WebSocket-reititin, tehtävien jakelu, admin HTML, Basic Auth
|
||||||
|
│ └── db.rs # SQLite: node_sessions, pair_results
|
||||||
├── node/ # Selainsolmu (Rust → Wasm)
|
├── node/ # Selainsolmu (Rust → Wasm)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── lib.rs # WebGPU/NdArray-laskenta, tokenisaatio, WS-yhteys
|
│ ├── lib.rs # Wasm-entrypoint, tehtävävalinta, WS-handler
|
||||||
│ └── storage.rs # IndexedDB-välimuisti (tokenizer)
|
│ ├── storage.rs # IndexedDB-välimuisti
|
||||||
├── native-node/ # Natiivi-solmu (Rust)
|
│ ├── sampling.rs # Top-k sampling (EOS-penaltti)
|
||||||
│ └── src/main.rs # NVML GPU-tunnistus, sysinfo, WS-yhteys
|
│ ├── smollm.rs # SmolLM 135M inferenssi
|
||||||
|
│ ├── qwen.rs # Qwen2.5 0.5B inferenssi
|
||||||
|
│ ├── qwen_coder.rs # Qwen2.5-Coder 0.5B/3B koodigenerointi
|
||||||
|
│ └── phi3.rs # Phi-3 placeholder
|
||||||
|
├── native-node/ # Natiivi-solmu (Rust + CUDA)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.rs # GPU-tunnistus, WS-yhteys, tehtäväkäsittely
|
||||||
|
│ └── inference.rs # Qwen2.5-0.5B Candle-inferenssi (CUDA/CPU)
|
||||||
├── static/
|
├── static/
|
||||||
│ ├── index.html # Dashboard-käyttöliittymä
|
│ ├── index.html # Dashboard + Koodilaboratorio
|
||||||
│ └── pkg/ # Wasm-build (generoidaan)
|
│ └── pkg/ # Wasm-build (generoidaan)
|
||||||
├── docker-compose.yml
|
├── deploy.sh # Lokaali build → palvelimelle
|
||||||
├── Dockerfile.dev # Hub + Wasm-build
|
├── docker-compose.yml # Kehitys
|
||||||
└── Dockerfile.native-node
|
├── docker-compose.prod.yml # Tuotanto (Caddy + Hub)
|
||||||
|
├── docker-compose.client.yml # Client-nodejen Docker
|
||||||
|
├── Dockerfile.prod # Tuotanto-image (cache mount)
|
||||||
|
└── Caddyfile.prod # TLS + reverse proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ympäristömuuttujat
|
## Ympäristömuuttujat
|
||||||
@@ -103,15 +147,27 @@ network-poc/
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `HUB_URL` | `ws://hub:3000/ws` | Hub-palvelimen WebSocket-osoite (native-node) |
|
| `HUB_URL` | `ws://hub:3000/ws` | Hub-palvelimen WebSocket-osoite (native-node) |
|
||||||
| `ALLOCATED_GB` | `4` | Solmun varaama muisti verkosta (GB) |
|
| `ALLOCATED_GB` | `4` | Solmun varaama muisti verkosta (GB) |
|
||||||
|
| `ADMIN_PASSWORD` | `kipina` | Admin-sivun ja API:n salasana (Basic Auth) |
|
||||||
|
| `DATABASE_PATH` | `nodes.db` | SQLite-tietokannan polku |
|
||||||
|
| `STATIC_DIR` | `../static` | Staattisten tiedostojen kansio |
|
||||||
|
|
||||||
## Kehitysvaihe
|
## Admin-sivu
|
||||||
|
|
||||||
Tämä on proof-of-concept. Toimivat osat:
|
`https://kipina.studio/admin` (Basic Auth, salasana: `ADMIN_PASSWORD`)
|
||||||
- Hub-palvelin, WebSocket-viestintä, dashboard
|
|
||||||
- WebGPU-tensorilaskenta selaimessa (Burn + Wgpu)
|
|
||||||
- CPU-fallback selaimissa ilman WebGPU-tukea (Burn + NdArray)
|
|
||||||
- Natiivi-node nvidia-smi-tason laitteistotiedoilla
|
|
||||||
- Qwen2.5-Coder-tokenizer + IndexedDB-välimuisti
|
|
||||||
- GPU-kuormituksen säätö (duty cycle throttling)
|
|
||||||
|
|
||||||
Seuraavaksi: oikea LLM-inferenssi hajautetusti (mallin painojen lataus, transformer-arkkitehtuuri Wasm/WebGPU:lla).
|
Sisältää:
|
||||||
|
- Node-sessiot: IP, laitetiedot, GPU, WebGPU-tuki, tehtävätyyppi, uptime
|
||||||
|
- Tokenisointitulokset: EN/FI-vertailut, ylikustannus-%
|
||||||
|
- Yhteenvetotilastot: sessiot, WebGPU vs CPU, keskiarvot
|
||||||
|
|
||||||
|
## Projektin tila
|
||||||
|
|
||||||
|
Toimivat ominaisuudet:
|
||||||
|
- Tokenisointivertailu (EN/FI, BPE, top-k sampling)
|
||||||
|
- SmolLM 135M inferenssi selaimessa (Candle + Wasm)
|
||||||
|
- Qwen2.5 0.5B inferenssi selaimessa (Candle + Wasm)
|
||||||
|
- Qwen2.5-Coder 0.5B/3B koodigenerointi (Koodilaboratorio-välilehti)
|
||||||
|
- Native node + CUDA (RTX 4090: ~50-100 tok/s)
|
||||||
|
- Admin-dashboard + SQLite + Basic Auth
|
||||||
|
- Deploy-skripti (lokaali build → palvelin)
|
||||||
|
- WebGPU + CPU fallback, GPU-tunnistus (NVIDIA/AMD/Apple)
|
||||||
|
|||||||
4
network-poc/cargo-errors.log
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
error: failed to write `/home/jaakko/code/kipinä/digikipinae/agentic-office/network-poc/target/wasm32-unknown-unknown/debug/.fingerprint/num-traits-0a015ef9fd3732e0/run-build-script-build-script-build`
|
||||||
|
|
||||||
|
Caused by:
|
||||||
|
Permission denied (os error 13)
|
||||||
15
network-poc/cli/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.6.0", features = ["derive"] }
|
||||||
|
console = "0.16.3"
|
||||||
|
indicatif = "0.18.4"
|
||||||
|
reqwest = { version = "0.13.2", features = ["json"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
serde_yaml = "0.9.34"
|
||||||
|
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros"] }
|
||||||
|
uuid = { version = "1.23.0", features = ["v4"] }
|
||||||
165
network-poc/cli/src/main.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "kpn")]
|
||||||
|
#[command(about = "Kipinä Agent Local CLI", long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Alustaa uuden Kipinä-agenttikansion nykyiseen projektiin
|
||||||
|
Init {
|
||||||
|
#[arg(short, long, default_value = "kipina-tasks")]
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
|
/// Ajaa `.md` tiedostossa kuvatun tehtävän Kipinä-verkoston kautta
|
||||||
|
Run {
|
||||||
|
/// Polku `.md` työtiedostoon
|
||||||
|
file: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct Frontmatter {
|
||||||
|
agent: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
context: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CompletionRequest {
|
||||||
|
model: String,
|
||||||
|
prompt: String,
|
||||||
|
task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CompletionResponse {
|
||||||
|
response: String,
|
||||||
|
model: String,
|
||||||
|
tokens_generated: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match &cli.command {
|
||||||
|
Commands::Init { dir } => {
|
||||||
|
let path = Path::new(dir);
|
||||||
|
if !path.exists() {
|
||||||
|
fs::create_dir_all(path).unwrap();
|
||||||
|
let example = format!("---\nstatus: open\nagent: qwen-coder-3b\ncontext: []\n---\n\n# Tehtävä\nKirjoita tähän mitä haluat verkon koodaavan.");
|
||||||
|
fs::write(path.join("01-esimerkki.md"), example).unwrap();
|
||||||
|
println!("✅ Alustettu lokaali agenttikansio: {}", dir);
|
||||||
|
} else {
|
||||||
|
println!("⚠️ Kansio {} on jo olemassa.", dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Run { file } => {
|
||||||
|
if let Err(e) = run_workflow(file).await {
|
||||||
|
eprintln!("❌ Virhe: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_workflow(filepath: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let content = fs::read_to_string(filepath)?;
|
||||||
|
|
||||||
|
// Yksinkertainen frontmatter-parseri
|
||||||
|
let mut frontmatter_str = String::new();
|
||||||
|
let mut body = String::new();
|
||||||
|
let mut in_frontmatter = false;
|
||||||
|
let mut fm_found = false;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
if line.trim() == "---" {
|
||||||
|
if !fm_found {
|
||||||
|
in_frontmatter = true;
|
||||||
|
fm_found = true;
|
||||||
|
continue;
|
||||||
|
} else if in_frontmatter {
|
||||||
|
in_frontmatter = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_frontmatter {
|
||||||
|
frontmatter_str.push_str(line);
|
||||||
|
frontmatter_str.push('\n');
|
||||||
|
} else {
|
||||||
|
body.push_str(line);
|
||||||
|
body.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta: Frontmatter = if fm_found {
|
||||||
|
serde_yaml::from_str(&frontmatter_str).unwrap_or(Frontmatter { agent: None, status: None, context: None })
|
||||||
|
} else {
|
||||||
|
Frontmatter { agent: None, status: None, context: None }
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = meta.agent.unwrap_or_else(|| "qwen-coder-05b".to_string());
|
||||||
|
|
||||||
|
// Kerätään kontekstitiedostot
|
||||||
|
let mut mega_prompt = body.trim().to_string();
|
||||||
|
if let Some(ctx_files) = meta.context {
|
||||||
|
mega_prompt.push_str("\n\n=== KONTEKSTI ===\n");
|
||||||
|
for ctx in ctx_files {
|
||||||
|
if let Ok(c) = fs::read_to_string(&ctx) {
|
||||||
|
mega_prompt.push_str(&format!("\n--- Tiedosto: {} ---\n{}\n", ctx, c));
|
||||||
|
} else {
|
||||||
|
println!("⚠️ Varoitus: Kontekstitiedostoa {} ei löytynyt.", ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n🚀 Lähetetään tehtävä Kipinäverkkoon (Malli: {})", model);
|
||||||
|
|
||||||
|
let pb = ProgressBar::new_spinner();
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(100));
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {msg}")
|
||||||
|
.unwrap()
|
||||||
|
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
|
||||||
|
);
|
||||||
|
pb.set_message("Odotetaan verkon solmua ja laskentaa...");
|
||||||
|
|
||||||
|
let task_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: model.clone(),
|
||||||
|
prompt: mega_prompt.clone(),
|
||||||
|
task_id: task_id.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = client.post("http://localhost:3000/api/v1/chat/completions")
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if res.status().is_success() {
|
||||||
|
let completion: CompletionResponse = res.json().await?;
|
||||||
|
pb.finish_with_message(format!("Tulos saapui verkolta! ({} tokenia)", completion.tokens_generated));
|
||||||
|
|
||||||
|
let new_content = format!("{}\n\n## Kipinä Agentin Ratkaisu\n{}\n", content, completion.response);
|
||||||
|
let updated_content = new_content.replace("status: open", "status: done");
|
||||||
|
fs::write(filepath, updated_content)?;
|
||||||
|
println!("✅ Vastaus tallennettu tiedostoon: {}", filepath);
|
||||||
|
} else {
|
||||||
|
pb.finish_with_message("❌ Verkkopyyntö epäonnistui!");
|
||||||
|
println!("Virhekoodi: {}", res.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,26 +1,70 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" == "local" ]; then
|
||||||
|
echo "=== Kipinä Studio Local Development ==="
|
||||||
|
echo "Käynnistetään kokonaisuus puhtaasti Docker-kontissa..."
|
||||||
|
docker compose up agentic-poc
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
SERVER="ubuntu@86.50.252.98"
|
SERVER="ubuntu@86.50.252.98"
|
||||||
REMOTE_DIR="~/code/agentic-studio/network-poc"
|
REMOTE_DIR="~/code/agentic-studio/network-poc"
|
||||||
SSH_OPTS="-o StrictHostKeyChecking=no"
|
KEY="$HOME/.ssh/id_rsa"
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -i $KEY"
|
||||||
|
|
||||||
|
# Varmistetaan, että SSH-avain on agentissa
|
||||||
|
if ! ssh-add -l 2>/dev/null | grep -q id_rsa; then
|
||||||
|
echo "SSH-avain ei ole agentissa. Lisätään..."
|
||||||
|
ssh-add "$KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== Kipinä Studio Deploy ==="
|
echo "=== Kipinä Studio Deploy ==="
|
||||||
|
|
||||||
|
# 0. Commitoidaan uncommitted muutokset ennen deployta
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null || \
|
||||||
|
[ -n "$(git -C "$SCRIPT_DIR" ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
||||||
|
echo "[0] Uncommitted muutoksia havaittu — commitoidaan..."
|
||||||
|
read -rp " Commit-viesti: " DEPLOY_MSG
|
||||||
|
if [ -z "$DEPLOY_MSG" ]; then
|
||||||
|
DEPLOY_MSG="Deploy $(date +%Y-%m-%d\ %H:%M)"
|
||||||
|
fi
|
||||||
|
git -C "$SCRIPT_DIR" add -A
|
||||||
|
git -C "$SCRIPT_DIR" commit -m "$DEPLOY_MSG"
|
||||||
|
echo " Commitoitu: $DEPLOY_MSG"
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Rakennetaan Docker-image lokaalisti
|
# 1. Rakennetaan Docker-image lokaalisti
|
||||||
echo "[1/4] Rakennetaan image lokaalisti..."
|
echo "[1/4] Rakennetaan image lokaalisti..."
|
||||||
docker build -f Dockerfile.prod -t kipina-agentic:latest .
|
docker build --platform linux/amd64 -f Dockerfile.prod -t kipina-agentic:latest .
|
||||||
|
|
||||||
# 2. Tallennetaan ja siirretään
|
# 2. Tallennetaan tiedostoon
|
||||||
echo "[2/4] Siirretään image palvelimelle..."
|
echo "[2/5] Pakataan image..."
|
||||||
docker save kipina-agentic:latest | gzip | ssh $SSH_OPTS $SERVER "gunzip | docker load"
|
docker save kipina-agentic:latest | gzip > /tmp/kipina-agentic.tar.gz
|
||||||
|
echo " Koko: $(du -h /tmp/kipina-agentic.tar.gz | cut -f1)"
|
||||||
|
|
||||||
# 3. Päivitetään konfiguraatiot
|
# 3. Siirretään palvelimelle
|
||||||
echo "[3/4] Päivitetään konfiguraatiot..."
|
echo "[3/5] Siirretään palvelimelle..."
|
||||||
|
scp $SSH_OPTS /tmp/kipina-agentic.tar.gz $SERVER:/tmp/
|
||||||
scp $SSH_OPTS docker-compose.prod.yml Caddyfile.prod $SERVER:$REMOTE_DIR/
|
scp $SSH_OPTS docker-compose.prod.yml Caddyfile.prod $SERVER:$REMOTE_DIR/
|
||||||
|
|
||||||
# 4. Käynnistetään uudelleen
|
# 4. Ladataan image ja käynnistetään
|
||||||
echo "[4/4] Käynnistetään palvelut..."
|
echo "[4/5] Ladataan image palvelimella..."
|
||||||
ssh $SSH_OPTS $SERVER "cd $REMOTE_DIR && docker compose -f docker-compose.prod.yml up -d"
|
ssh $SSH_OPTS $SERVER "gunzip -c /tmp/kipina-agentic.tar.gz | docker load && rm /tmp/kipina-agentic.tar.gz"
|
||||||
|
|
||||||
|
echo "[5/5] Käynnistetään palvelut uudelleen..."
|
||||||
|
ssh $SSH_OPTS $SERVER "cd $REMOTE_DIR && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d"
|
||||||
|
|
||||||
echo "=== Valmis! https://kipina.studio ==="
|
echo "=== Valmis! https://kipina.studio ==="
|
||||||
|
|
||||||
|
# Discord-notifikaatio
|
||||||
|
DISCORD_WEBHOOK="https://discord.com/api/webhooks/1489504066898755687/8U02d0wug-3MkVax0xMmRoj0s_-V1psnNLPWdSOjnGnKRBUpPjaU6XiX9Iu8DgJI69AP"
|
||||||
|
COMMIT_HASH=$(git -C "$SCRIPT_DIR" log -1 --pretty=format:"%h" 2>/dev/null || echo "?")
|
||||||
|
COMMIT_MSG=$(git -C "$SCRIPT_DIR" log -1 --pretty=format:"%s" 2>/dev/null || echo "?")
|
||||||
|
# python3 escapettaa erikoismerkit JSON-turvallisesti
|
||||||
|
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'content': sys.argv[1]}))" \
|
||||||
|
"🚀 **Kipinä Studio julkaistu!**
|
||||||
|
> \`${COMMIT_HASH}\` ${COMMIT_MSG}
|
||||||
|
> https://kipina.studio")
|
||||||
|
curl -s -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK" > /dev/null
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hub"
|
name = "hub"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7.4", features = ["ws", "macros"] }
|
axum = { version = "0.7.4", features = ["ws", "macros"] }
|
||||||
tokio = { version = "1.36.0", features = ["full"] }
|
tokio = { version = "1.36.0", features = ["full", "sync"] }
|
||||||
tower-http = { version = "0.5.2", features = ["fs", "cors", "trace"] }
|
tower-http = { version = "0.5.2", features = ["fs", "cors", "trace"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -15,3 +15,4 @@ uuid = { version = "1.7.0", features = ["v4", "serde"] }
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
BIN
network-poc/hub/nodes.db
Normal file
@@ -9,6 +9,24 @@ impl NodeDb {
|
|||||||
pub fn new(path: &str) -> Self {
|
pub fn new(path: &str) -> Self {
|
||||||
let conn = Connection::open(path).expect("SQLite-tietokantaa ei voitu avata");
|
let conn = Connection::open(path).expect("SQLite-tietokantaa ei voitu avata");
|
||||||
|
|
||||||
|
// Poista vanha tietokanta jos skeema on rikki — PoC, ei tuotantodata
|
||||||
|
let _ = conn.execute_batch("
|
||||||
|
CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER);
|
||||||
|
");
|
||||||
|
let version: i64 = conn.query_row(
|
||||||
|
"SELECT COALESCE(MAX(version), 0) FROM _schema_version", [], |r| r.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
if version < 2 {
|
||||||
|
// Pudotetaan vanhat taulut ja luodaan uudet
|
||||||
|
let _ = conn.execute_batch("
|
||||||
|
DROP TABLE IF EXISTS node_sessions;
|
||||||
|
DROP TABLE IF EXISTS pair_results;
|
||||||
|
DELETE FROM _schema_version;
|
||||||
|
INSERT INTO _schema_version VALUES (2);
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
conn.execute_batch("
|
conn.execute_batch("
|
||||||
CREATE TABLE IF NOT EXISTS node_sessions (
|
CREATE TABLE IF NOT EXISTS node_sessions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -35,8 +53,9 @@ impl NodeDb {
|
|||||||
gpu_temp_c INTEGER,
|
gpu_temp_c INTEGER,
|
||||||
gpu_util_pct INTEGER,
|
gpu_util_pct INTEGER,
|
||||||
|
|
||||||
-- Varaus
|
-- Varaus ja tehtävä
|
||||||
allocated_gb INTEGER,
|
allocated_gb INTEGER,
|
||||||
|
selected_task TEXT DEFAULT 'tokenize',
|
||||||
|
|
||||||
-- WebGPU-tuki
|
-- WebGPU-tuki
|
||||||
has_webgpu BOOLEAN,
|
has_webgpu BOOLEAN,
|
||||||
@@ -70,7 +89,7 @@ impl NodeDb {
|
|||||||
node_type: &str,
|
node_type: &str,
|
||||||
auth_data: &serde_json::Value,
|
auth_data: &serde_json::Value,
|
||||||
) -> i64 {
|
) -> i64 {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
// Selainsolmun tiedot
|
// Selainsolmun tiedot
|
||||||
@@ -78,6 +97,7 @@ impl NodeDb {
|
|||||||
let cpu_cores = auth_data.get("cpu_cores").and_then(|v| v.as_u64());
|
let cpu_cores = auth_data.get("cpu_cores").and_then(|v| v.as_u64());
|
||||||
let ram = auth_data.get("device_memory_gb").and_then(|v| v.as_f64()).map(|v| (v * 1024.0) as i64);
|
let ram = auth_data.get("device_memory_gb").and_then(|v| v.as_f64()).map(|v| (v * 1024.0) as i64);
|
||||||
let allocated = auth_data.get("allocated_gb").and_then(|v| v.as_u64());
|
let allocated = auth_data.get("allocated_gb").and_then(|v| v.as_u64());
|
||||||
|
let selected_task = auth_data.get("selected_task").and_then(|v| v.as_str());
|
||||||
|
|
||||||
// GPU (selain)
|
// GPU (selain)
|
||||||
let gpu_vendor = auth_data.get("gpu").and_then(|g| g.get("vendor")).and_then(|v| v.as_str());
|
let gpu_vendor = auth_data.get("gpu").and_then(|g| g.get("vendor")).and_then(|v| v.as_str());
|
||||||
@@ -108,8 +128,8 @@ impl NodeDb {
|
|||||||
node_id, ip, node_type, connected_at,
|
node_id, ip, node_type, connected_at,
|
||||||
platform, hostname, os, cpu_cores, cpu_model, ram_mb,
|
platform, hostname, os, cpu_cores, cpu_model, ram_mb,
|
||||||
gpu_name, gpu_vendor, gpu_backend, vram_total_mb, vram_used_mb, gpu_temp_c, gpu_util_pct,
|
gpu_name, gpu_vendor, gpu_backend, vram_total_mb, vram_used_mb, gpu_temp_c, gpu_util_pct,
|
||||||
allocated_gb, has_webgpu
|
allocated_gb, selected_task, has_webgpu
|
||||||
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19)",
|
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)",
|
||||||
params![
|
params![
|
||||||
node_id as i64, ip, node_type, now,
|
node_id as i64, ip, node_type, now,
|
||||||
platform, hostname, os,
|
platform, hostname, os,
|
||||||
@@ -124,6 +144,7 @@ impl NodeDb {
|
|||||||
gpu_temp.map(|v| v as i64),
|
gpu_temp.map(|v| v as i64),
|
||||||
gpu_util.map(|v| v as i64),
|
gpu_util.map(|v| v as i64),
|
||||||
allocated.map(|v| v as i64),
|
allocated.map(|v| v as i64),
|
||||||
|
selected_task,
|
||||||
has_webgpu,
|
has_webgpu,
|
||||||
],
|
],
|
||||||
).expect("Session insert epäonnistui");
|
).expect("Session insert epäonnistui");
|
||||||
@@ -131,8 +152,26 @@ impl NodeDb {
|
|||||||
conn.last_insert_rowid()
|
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) {
|
pub fn close_session(&self, node_id: u64) {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"UPDATE node_sessions SET disconnected_at = ?1 WHERE node_id = ?2 AND disconnected_at IS NULL",
|
"UPDATE node_sessions SET disconnected_at = ?1 WHERE node_id = ?2 AND disconnected_at IS NULL",
|
||||||
@@ -141,7 +180,7 @@ impl NodeDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_tasks(&self, node_id: u64) {
|
pub fn increment_tasks(&self, node_id: u64) {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"UPDATE node_sessions SET tasks_completed = tasks_completed + 1 WHERE node_id = ?1 AND disconnected_at IS NULL",
|
"UPDATE node_sessions SET tasks_completed = tasks_completed + 1 WHERE node_id = ?1 AND disconnected_at IS NULL",
|
||||||
params![node_id as i64],
|
params![node_id as i64],
|
||||||
@@ -149,12 +188,12 @@ impl NodeDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_sessions(&self, limit: u32) -> Vec<serde_json::Value> {
|
pub fn get_sessions(&self, limit: u32) -> Vec<serde_json::Value> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, node_id, ip, node_type, connected_at, disconnected_at,
|
"SELECT id, node_id, ip, node_type, connected_at, disconnected_at,
|
||||||
platform, hostname, os, cpu_cores, cpu_model, ram_mb,
|
platform, hostname, os, cpu_cores, cpu_model, ram_mb,
|
||||||
gpu_name, gpu_vendor, gpu_backend, vram_total_mb, gpu_temp_c, gpu_util_pct,
|
gpu_name, gpu_vendor, gpu_backend, vram_total_mb, gpu_temp_c, gpu_util_pct,
|
||||||
allocated_gb, has_webgpu, tasks_completed
|
allocated_gb, selected_task, has_webgpu, tasks_completed
|
||||||
FROM node_sessions ORDER BY id DESC LIMIT ?1"
|
FROM node_sessions ORDER BY id DESC LIMIT ?1"
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
@@ -179,14 +218,15 @@ impl NodeDb {
|
|||||||
"gpu_temp_c": row.get::<_, Option<i64>>(16)?,
|
"gpu_temp_c": row.get::<_, Option<i64>>(16)?,
|
||||||
"gpu_util_pct": row.get::<_, Option<i64>>(17)?,
|
"gpu_util_pct": row.get::<_, Option<i64>>(17)?,
|
||||||
"allocated_gb": row.get::<_, Option<i64>>(18)?,
|
"allocated_gb": row.get::<_, Option<i64>>(18)?,
|
||||||
"has_webgpu": row.get::<_, Option<bool>>(19)?,
|
"selected_task": row.get::<_, Option<String>>(19)?,
|
||||||
"tasks_completed": row.get::<_, i64>(20)?,
|
"has_webgpu": row.get::<_, Option<bool>>(20)?,
|
||||||
|
"tasks_completed": row.get::<_, i64>(21)?,
|
||||||
}))
|
}))
|
||||||
}).unwrap().filter_map(|r| r.ok()).collect()
|
}).unwrap().filter_map(|r| r.ok()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_pair_results(&self, limit: u32) -> Vec<serde_json::Value> {
|
pub fn get_pair_results(&self, limit: u32) -> Vec<serde_json::Value> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, node_id, created_at, en_text, fi_text,
|
"SELECT id, node_id, created_at, en_text, fi_text,
|
||||||
en_tokens, fi_tokens, en_chars_per_token, fi_chars_per_token,
|
en_tokens, fi_tokens, en_chars_per_token, fi_chars_per_token,
|
||||||
@@ -212,7 +252,7 @@ impl NodeDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_stats(&self) -> serde_json::Value {
|
pub fn get_stats(&self) -> serde_json::Value {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
let total_sessions: i64 = conn.query_row("SELECT COUNT(*) FROM node_sessions", [], |r| r.get(0)).unwrap_or(0);
|
let total_sessions: i64 = conn.query_row("SELECT COUNT(*) FROM node_sessions", [], |r| r.get(0)).unwrap_or(0);
|
||||||
let active_sessions: i64 = conn.query_row("SELECT COUNT(*) FROM node_sessions WHERE disconnected_at IS NULL", [], |r| r.get(0)).unwrap_or(0);
|
let active_sessions: i64 = conn.query_row("SELECT COUNT(*) FROM node_sessions WHERE disconnected_at IS NULL", [], |r| r.get(0)).unwrap_or(0);
|
||||||
@@ -247,7 +287,7 @@ impl NodeDb {
|
|||||||
overhead: f64,
|
overhead: f64,
|
||||||
duration_ms: f64,
|
duration_ms: f64,
|
||||||
) {
|
) {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"INSERT INTO pair_results (
|
"INSERT INTO pair_results (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::collections::HashMap;
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
mod db;
|
mod db;
|
||||||
@@ -25,15 +25,23 @@ const ALLOWED_ORIGINS: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Sallitut viestityyypit clientilta
|
// Sallitut viestityyypit clientilta
|
||||||
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk"];
|
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "llm_error", "download_progress", "user_text", "single_tokenize_done"];
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
next_node_id: Mutex<u64>,
|
next_node_id: Mutex<u64>,
|
||||||
nodes_vram: Mutex<HashMap<u64, u32>>,
|
nodes_vram: Mutex<HashMap<u64, u32>>,
|
||||||
|
nodes_tokens: Mutex<HashMap<u64, u32>>, // Gamification: Kipinä Tokens
|
||||||
total_tasks: Mutex<u64>,
|
total_tasks: Mutex<u64>,
|
||||||
stats_tx: broadcast::Sender<String>,
|
stats_tx: broadcast::Sender<String>,
|
||||||
|
node_channels: tokio::sync::RwLock<HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>>, // Kohdennettu reititys
|
||||||
|
pending_consensus: tokio::sync::RwLock<HashMap<String, Vec<serde_json::Value>>>, // Proof of Compute -konsensus
|
||||||
|
feature_flags: tokio::sync::RwLock<HashMap<String, bool>>, // Tuntee TODO.md:n ruksit lennosta
|
||||||
ip_connections: Mutex<HashMap<IpAddr, u32>>,
|
ip_connections: Mutex<HashMap<IpAddr, u32>>,
|
||||||
node_ips: Mutex<HashMap<u64, IpAddr>>,
|
node_ips: Mutex<HashMap<u64, IpAddr>>,
|
||||||
|
node_tasks: Mutex<HashMap<u64, String>>, // node_id → selected_task
|
||||||
|
node_busy: Mutex<std::collections::HashSet<u64>>, // Solmut joilla on aktiivinen tehtävä
|
||||||
|
pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi)
|
||||||
|
api_rate_limits: Mutex<HashMap<IpAddr, (std::time::Instant, u32)>>, // IP → (ikkuna-alku, pyyntömäärä)
|
||||||
db: db::NodeDb,
|
db: db::NodeDb,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +61,11 @@ h1 { color:var(--accent); margin-bottom:5px; }
|
|||||||
.stat-card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:16px; text-align:center; }
|
.stat-card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:16px; text-align:center; }
|
||||||
.stat-card .val { font-size:28px; font-weight:700; color:var(--accent); }
|
.stat-card .val { font-size:28px; font-weight:700; color:var(--accent); }
|
||||||
.stat-card .label { font-size:12px; color:#8b949e; margin-top:4px; }
|
.stat-card .label { font-size:12px; color:#8b949e; margin-top:4px; }
|
||||||
table { width:100%; border-collapse:collapse; margin-bottom:24px; font-size:13px; }
|
table { width:100%; border-collapse:collapse; margin-bottom:24px; font-size:13px; table-layout:fixed; }
|
||||||
th { background:var(--panel); color:var(--accent); text-align:left; padding:10px 8px; border-bottom:2px solid var(--border); position:sticky; top:0; }
|
th { background:var(--panel); color:var(--accent); text-align:left; padding:10px 8px; border-bottom:2px solid var(--border); position:sticky; top:0; z-index:1; white-space:nowrap; overflow:hidden; }
|
||||||
td { padding:8px; border-bottom:1px solid var(--border); }
|
td { padding:8px; border-bottom:1px solid var(--border); height:36px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
tr:hover td { background:#1c2333; }
|
tr:hover td { background:#1c2333; }
|
||||||
|
.table-wrap { max-height:60vh; overflow-y:auto; border:1px solid var(--border); border-radius:6px; }
|
||||||
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
|
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||||
.badge-green { background:#23392050; color:var(--green); border:1px solid #23392080; }
|
.badge-green { background:#23392050; color:var(--green); border:1px solid #23392080; }
|
||||||
.badge-yellow { background:#d2992220; color:var(--yellow); border:1px solid #d2992240; }
|
.badge-yellow { background:#d2992220; color:var(--yellow); border:1px solid #d2992240; }
|
||||||
@@ -85,8 +94,14 @@ tr:hover td { background:#1c2333; }
|
|||||||
|
|
||||||
<div id="sessions" class="panel active">
|
<div id="sessions" class="panel active">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table><thead><tr>
|
<table>
|
||||||
<th>ID</th><th>Tila</th><th>Tyyppi</th><th>IP</th><th>Alusta</th>
|
<colgroup>
|
||||||
|
<col style="width:35px"><col style="width:85px"><col style="width:95px"><col style="width:65px"><col style="width:110px"><col style="width:80px">
|
||||||
|
<col style="width:65px"><col style="width:40px"><col style="width:70px"><col style="width:90px"><col style="width:60px">
|
||||||
|
<col style="width:65px"><col style="width:40px"><col style="width:130px"><col style="width:60px">
|
||||||
|
</colgroup>
|
||||||
|
<thead><tr>
|
||||||
|
<th>ID</th><th>Tila</th><th>Tehtävä</th><th>Tyyppi</th><th>IP</th><th>Alusta</th>
|
||||||
<th>OS</th><th>CPU</th><th>RAM</th><th>GPU</th><th>VRAM</th>
|
<th>OS</th><th>CPU</th><th>RAM</th><th>GPU</th><th>VRAM</th>
|
||||||
<th>WebGPU</th><th>Teht.</th><th>Yhdistetty</th><th>Kesto</th>
|
<th>WebGPU</th><th>Teht.</th><th>Yhdistetty</th><th>Kesto</th>
|
||||||
</tr></thead><tbody id="sessions-body"></tbody></table>
|
</tr></thead><tbody id="sessions-body"></tbody></table>
|
||||||
@@ -156,11 +171,30 @@ async function load() {
|
|||||||
{v: stats.avg_overhead_pct + '%', l: 'FI ylikust. (ka.)'},
|
{v: stats.avg_overhead_pct + '%', l: 'FI ylikust. (ka.)'},
|
||||||
].map(s => `<div class="stat-card"><div class="val">${s.v}</div><div class="label">${s.l}</div></div>`).join('');
|
].map(s => `<div class="stat-card"><div class="val">${s.v}</div><div class="label">${s.l}</div></div>`).join('');
|
||||||
|
|
||||||
// Sessions
|
// 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','codelab-viewer':'Koodilabra'};
|
||||||
|
sessions.sort((a, b) => {
|
||||||
|
const aOnline = !a.disconnected_at;
|
||||||
|
const bOnline = !b.disconnected_at;
|
||||||
|
const aViewer = a.selected_task === 'viewer';
|
||||||
|
const bViewer = b.selected_task === 'viewer';
|
||||||
|
// Online ennen offlinea
|
||||||
|
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
||||||
|
// Online: aktiiviset nodet ennen katsojia
|
||||||
|
if (aOnline && bOnline && aViewer !== bViewer) return aViewer ? 1 : -1;
|
||||||
|
// Saman ryhmän sisällä: uusin ensin
|
||||||
|
return new Date(b.connected_at) - new Date(a.connected_at);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('sessions-body').innerHTML = sessions.map(s => {
|
document.getElementById('sessions-body').innerHTML = sessions.map(s => {
|
||||||
const online = !s.disconnected_at;
|
const online = !s.disconnected_at;
|
||||||
const status = online ? '<span class="online">ONLINE</span>' : '<span class="offline">offline</span>';
|
const isViewer = s.selected_task === 'viewer';
|
||||||
|
const status = online
|
||||||
|
? (isViewer ? '<span style="color:#d29922">CONNECTED</span>' : '<span class="online">ACTIVE</span>')
|
||||||
|
: '<span class="offline">offline</span>';
|
||||||
const typeBadge = s.node_type === 'native' ? badge('native','blue') : badge('browser','yellow');
|
const typeBadge = s.node_type === 'native' ? badge('native','blue') : badge('browser','yellow');
|
||||||
|
const taskColor = isViewer ? 'yellow' : s.selected_task === 'tokenize' ? 'green' : 'blue';
|
||||||
|
const taskBadge = badge(taskNames[s.selected_task] || s.selected_task || '?', taskColor);
|
||||||
const gpuBadge = s.has_webgpu ? badge('WebGPU','green') : badge('CPU','red');
|
const gpuBadge = s.has_webgpu ? badge('WebGPU','green') : badge('CPU','red');
|
||||||
const gpu = s.gpu_name ? `${s.gpu_name}` : '-';
|
const gpu = s.gpu_name ? `${s.gpu_name}` : '-';
|
||||||
const vram = s.vram_total_mb ? `${s.vram_total_mb} MB` : '-';
|
const vram = s.vram_total_mb ? `${s.vram_total_mb} MB` : '-';
|
||||||
@@ -171,7 +205,7 @@ async function load() {
|
|||||||
const time = s.connected_at ? new Date(s.connected_at).toLocaleString('fi-FI') : '';
|
const time = s.connected_at ? new Date(s.connected_at).toLocaleString('fi-FI') : '';
|
||||||
const dur = duration(s.connected_at, s.disconnected_at);
|
const dur = duration(s.connected_at, s.disconnected_at);
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${s.node_id}</td><td>${status}</td><td>${typeBadge}</td><td>${s.ip}</td>
|
<td>${s.node_id}</td><td>${status}</td><td>${taskBadge}</td><td>${typeBadge}</td><td>${s.ip}</td>
|
||||||
<td>${plat}</td><td>${os}</td><td>${cores}</td><td>${ram}</td>
|
<td>${plat}</td><td>${os}</td><td>${cores}</td><td>${ram}</td>
|
||||||
<td>${gpu}</td><td>${vram}</td><td>${gpuBadge}</td>
|
<td>${gpu}</td><td>${vram}</td><td>${gpuBadge}</td>
|
||||||
<td>${s.tasks_completed}</td><td>${time}</td><td>${dur}</td>
|
<td>${s.tasks_completed}</td><td>${time}</td><td>${dur}</td>
|
||||||
@@ -197,7 +231,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
setInterval(load, 1000);
|
setInterval(load, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"##;
|
</html>"##;
|
||||||
@@ -217,15 +251,51 @@ async fn main() {
|
|||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
next_node_id: Mutex::new(1),
|
next_node_id: Mutex::new(1),
|
||||||
nodes_vram: Mutex::new(HashMap::new()),
|
nodes_vram: Mutex::new(HashMap::new()),
|
||||||
|
nodes_tokens: Mutex::new(HashMap::new()),
|
||||||
total_tasks: Mutex::new(0),
|
total_tasks: Mutex::new(0),
|
||||||
stats_tx: stats_tx.clone(),
|
stats_tx: stats_tx.clone(),
|
||||||
|
node_channels: tokio::sync::RwLock::new(HashMap::new()),
|
||||||
|
pending_consensus: tokio::sync::RwLock::new(HashMap::new()),
|
||||||
|
feature_flags: tokio::sync::RwLock::new(HashMap::new()),
|
||||||
ip_connections: Mutex::new(HashMap::new()),
|
ip_connections: Mutex::new(HashMap::new()),
|
||||||
node_ips: Mutex::new(HashMap::new()),
|
node_ips: Mutex::new(HashMap::new()),
|
||||||
|
node_tasks: Mutex::new(HashMap::new()),
|
||||||
|
node_busy: Mutex::new(std::collections::HashSet::new()),
|
||||||
|
pending_task_ids: Mutex::new(std::collections::HashSet::new()),
|
||||||
|
api_rate_limits: Mutex::new(HashMap::new()),
|
||||||
db: db::NodeDb::new(&std::env::var("DATABASE_PATH").unwrap_or_else(|_| "nodes.db".to_string())),
|
db: db::NodeDb::new(&std::env::var("DATABASE_PATH").unwrap_or_else(|_| "nodes.db".to_string())),
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("Tietokanta alustettu");
|
tracing::info!("Tietokanta alustettu");
|
||||||
|
|
||||||
|
let state_for_watcher = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Ensimmäinen luku heti, sitten 3s välein
|
||||||
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3));
|
||||||
|
let file_path = std::env::var("FEATURE_FLAGS_FILE").unwrap_or_else(|_| "../TODO.md".to_string());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Ok(content) = tokio::fs::read_to_string(&file_path).await {
|
||||||
|
let mut flags = HashMap::new();
|
||||||
|
for line in content.lines() {
|
||||||
|
if line.starts_with("- [ ] **") || line.starts_with("- [x] **") {
|
||||||
|
let is_active = line.starts_with("- [x]");
|
||||||
|
if let Some(start_idx) = line.find("**") {
|
||||||
|
let start = start_idx + 2;
|
||||||
|
if let Some(end_idx) = line[start..].find("**") {
|
||||||
|
let end = end_idx + start;
|
||||||
|
let feature_name = line[start..end].trim_end_matches(':').trim().to_string();
|
||||||
|
flags.insert(feature_name, is_active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*state_for_watcher.feature_flags.write().await = flags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let state_for_task = state.clone();
|
let state_for_task = state.clone();
|
||||||
|
|
||||||
// Ajastin, joka jakaa satunnaisia tekoälytehtäviä eri pituuksilla
|
// Ajastin, joka jakaa satunnaisia tekoälytehtäviä eri pituuksilla
|
||||||
@@ -258,13 +328,51 @@ async fn main() {
|
|||||||
let idx = (rng_state as usize) % pairs.len();
|
let idx = (rng_state as usize) % pairs.len();
|
||||||
let (en, fi) = pairs[idx];
|
let (en, fi) = pairs[idx];
|
||||||
|
|
||||||
let task_msg = serde_json::json!({
|
// Tokenisointiparit
|
||||||
|
let pair_msg = serde_json::json!({
|
||||||
"type": "pair_task",
|
"type": "pair_task",
|
||||||
"en": en,
|
"en": en,
|
||||||
"fi": fi,
|
"fi": fi,
|
||||||
});
|
});
|
||||||
tracing::debug!("Kielipari lähetetty: EN({}) vs FI({} merkkiä)", en.len(), fi.len());
|
let _ = state_for_task.stats_tx.send(pair_msg.to_string());
|
||||||
let _ = state_for_task.stats_tx.send(task_msg.to_string());
|
|
||||||
|
// LLM-promptit
|
||||||
|
let llm_prompts = vec![
|
||||||
|
"Tell me a short joke.",
|
||||||
|
"What is WebGPU in one sentence?",
|
||||||
|
"Explain distributed computing briefly.",
|
||||||
|
"Write a haiku about technology.",
|
||||||
|
"What makes Rust special?",
|
||||||
|
];
|
||||||
|
let llm_idx = (rng_state as usize / 7) % llm_prompts.len();
|
||||||
|
|
||||||
|
// SmolLM-prompt
|
||||||
|
let smollm_msg = serde_json::json!({
|
||||||
|
"type": "llm_prompt",
|
||||||
|
"prompt": llm_prompts[llm_idx],
|
||||||
|
"model": "smollm-135m",
|
||||||
|
});
|
||||||
|
let _ = state_for_task.stats_tx.send(smollm_msg.to_string());
|
||||||
|
|
||||||
|
// Qwen-prompt (sama prompti, eri malli-tagi)
|
||||||
|
let qwen_msg = serde_json::json!({
|
||||||
|
"type": "llm_prompt",
|
||||||
|
"prompt": llm_prompts[llm_idx],
|
||||||
|
"model": "qwen-05b",
|
||||||
|
});
|
||||||
|
let _ = state_for_task.stats_tx.send(qwen_msg.to_string());
|
||||||
|
|
||||||
|
// Phi-3 prompt
|
||||||
|
let phi3_msg = serde_json::json!({
|
||||||
|
"type": "llm_prompt",
|
||||||
|
"prompt": llm_prompts[llm_idx],
|
||||||
|
"model": "phi3-mini",
|
||||||
|
});
|
||||||
|
let _ = state_for_task.stats_tx.send(phi3_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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,8 +381,12 @@ async fn main() {
|
|||||||
.route("/api/sessions", get(api_sessions))
|
.route("/api/sessions", get(api_sessions))
|
||||||
.route("/api/pairs", get(api_pairs))
|
.route("/api/pairs", get(api_pairs))
|
||||||
.route("/api/stats", get(api_stats))
|
.route("/api/stats", get(api_stats))
|
||||||
|
.route("/api/v1/chat/completions", axum::routing::post(api_chat_completions))
|
||||||
.route("/admin", get(admin_page))
|
.route("/admin", get(admin_page))
|
||||||
.nest_service("/", ServeDir::new(std::env::var("STATIC_DIR").unwrap_or_else(|_| "../static".to_string())))
|
.nest_service("/", {
|
||||||
|
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../static".to_string());
|
||||||
|
ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir)))
|
||||||
|
})
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
||||||
@@ -285,27 +397,69 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn api_sessions(
|
async fn api_sessions(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> axum::response::Response {
|
||||||
axum::Json(state.db.get_sessions(200))
|
if !check_admin_auth(&headers) { return admin_unauthorized(); }
|
||||||
|
axum::Json(state.db.get_sessions(200)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_pairs(
|
async fn api_pairs(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> axum::response::Response {
|
||||||
axum::Json(state.db.get_pair_results(500))
|
if !check_admin_auth(&headers) { return admin_unauthorized(); }
|
||||||
|
axum::Json(state.db.get_pair_results(500)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_stats(
|
async fn api_stats(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> axum::response::Response {
|
||||||
|
if !check_admin_auth(&headers) { return admin_unauthorized(); }
|
||||||
let mut stats = state.db.get_stats();
|
let mut stats = state.db.get_stats();
|
||||||
stats.as_object_mut().unwrap().insert("version".to_string(), serde_json::json!(env!("CARGO_PKG_VERSION")));
|
if let Some(obj) = stats.as_object_mut() {
|
||||||
axum::Json(stats)
|
obj.insert("version".to_string(), serde_json::json!(env!("CARGO_PKG_VERSION")));
|
||||||
|
}
|
||||||
|
axum::Json(stats).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_page() -> impl IntoResponse {
|
fn check_admin_auth(headers: &axum::http::HeaderMap) -> bool {
|
||||||
axum::response::Html(ADMIN_HTML)
|
let password = match std::env::var("ADMIN_PASSWORD") {
|
||||||
|
Ok(p) if !p.is_empty() => p,
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("ADMIN_PASSWORD ei ole asetettu — käytetään oletusta 'kipina' (ÄLÄ käytä tuotannossa!)");
|
||||||
|
"kipina".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
|
||||||
|
if auth.starts_with("Basic ") {
|
||||||
|
use base64::Engine;
|
||||||
|
if let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(auth.trim_start_matches("Basic ").trim())
|
||||||
|
{
|
||||||
|
if let Ok(decoded) = String::from_utf8(decoded_bytes) {
|
||||||
|
if let Some(pass) = decoded.split(':').nth(1) {
|
||||||
|
return pass == password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_unauthorized() -> axum::response::Response {
|
||||||
|
axum::response::Response::builder()
|
||||||
|
.status(401)
|
||||||
|
.header("WWW-Authenticate", "Basic realm=\"Kipinä Admin\"")
|
||||||
|
.body(axum::body::Body::from("Unauthorized"))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_page(headers: axum::http::HeaderMap) -> axum::response::Response {
|
||||||
|
if !check_admin_auth(&headers) { return admin_unauthorized(); }
|
||||||
|
axum::response::Html(ADMIN_HTML).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ws_handler(
|
async fn ws_handler(
|
||||||
@@ -439,22 +593,35 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
|||||||
|
|
||||||
tracing::info!("Solmu {} yhdistyi osoitteesta {}", node_id, ip);
|
tracing::info!("Solmu {} yhdistyi osoitteesta {}", node_id, ip);
|
||||||
|
|
||||||
|
let (node_tx, mut node_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
|
||||||
|
// Tallennetaan node channel reititystä varten
|
||||||
|
{
|
||||||
|
state.node_channels.write().await.insert(node_id, node_tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yksinkertaistettu broadcast tx vastaanotto
|
||||||
let mut rx = state.stats_tx.subscribe();
|
let mut rx = state.stats_tx.subscribe();
|
||||||
|
|
||||||
let sender_task = tokio::spawn(async move {
|
let sender_task = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
tokio::select! {
|
||||||
Ok(msg) => {
|
result = rx.recv() => {
|
||||||
if sender.send(Message::Text(msg)).await.is_err() {
|
match result {
|
||||||
break;
|
Ok(msg) => {
|
||||||
|
if sender.send(Message::Text(msg)).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::debug!("Broadcast lagged {} viestiä — ohitetaan", n);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => break, // Kanava suljettu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
Some(direct_msg) = node_rx.recv() => {
|
||||||
continue;
|
if sender.send(Message::Text(direct_msg)).await.is_err() { break; }
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
else => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -476,7 +643,8 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
|||||||
let json = match validate_message(&text) {
|
let json = match validate_message(&text) {
|
||||||
Ok(j) => j,
|
Ok(j) => j,
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
tracing::warn!("Solmu {} ({}) lähetti virheellisen viestin: {} — {:?}", node_id, ip, reason, &text[..text.len().min(100)]);
|
let preview: String = text.chars().take(100).collect();
|
||||||
|
tracing::warn!("Solmu {} ({}) lähetti virheellisen viestin: {} — {:?}", node_id, ip, reason, preview);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -493,8 +661,22 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
|||||||
map.insert(node_id, allocated);
|
map.insert(node_id, allocated);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tallennetaan sessiotieto tietokantaan
|
let selected_task = json.get("selected_task").and_then(|v| v.as_str()).unwrap_or("tokenize").to_string();
|
||||||
state.db.insert_session(node_id, &ip.to_string(), node_type, &json);
|
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" {
|
if node_type == "native" {
|
||||||
let sys = json.get("system");
|
let sys = json.get("system");
|
||||||
@@ -529,10 +711,11 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
|||||||
.and_then(|g| g.get("description").or_else(|| g.get("vendor")))
|
.and_then(|g| g.get("description").or_else(|| g.get("vendor")))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("ei GPU:ta");
|
.unwrap_or("ei GPU:ta");
|
||||||
|
let task = json.get("selected_task").and_then(|v| v.as_str()).unwrap_or("tokenize");
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Solmu {} (selain) | {} | {} | {} ydintä | ~{} GB RAM | GPU: {} | varaus: {} GB",
|
"Solmu {} (selain) | {} | {} | {} ydintä | ~{} GB RAM | GPU: {} | tehtävä: {} | varaus: {} GB",
|
||||||
node_id, ip, platform, cores, ram, gpu_desc, allocated
|
node_id, ip, platform, cores, ram, gpu_desc, task, allocated
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -591,12 +774,42 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
|||||||
}
|
}
|
||||||
let _ = state.stats_tx.send(json.to_string());
|
let _ = state.stats_tx.send(json.to_string());
|
||||||
|
|
||||||
|
let active_incentives = state.feature_flags.read().await.get("Insentiivit").copied().unwrap_or(false);
|
||||||
|
let ui_sync = state.feature_flags.read().await.get("Pelimerkkien UI-synkkaus").copied().unwrap_or(false);
|
||||||
|
let mut current_balance = 0;
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut task_count = state.total_tasks.lock().unwrap();
|
let mut task_count = state.total_tasks.lock().unwrap();
|
||||||
*task_count += 1;
|
*task_count += 1;
|
||||||
|
|
||||||
|
if active_incentives {
|
||||||
|
let mut tokens = state.nodes_tokens.lock().unwrap();
|
||||||
|
let balance = tokens.entry(node_id).or_insert(0);
|
||||||
|
*balance += 5; // Palkkio: 5 Kipinä-merkkiä
|
||||||
|
current_balance = *balance;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if active_incentives && ui_sync {
|
||||||
|
if let Some(tx) = state.node_channels.read().await.get(&node_id) {
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "token_balance",
|
||||||
|
"balance": current_balance
|
||||||
|
});
|
||||||
|
let _ = tx.send(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
broadcast_stats(&state).await;
|
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" {
|
} else if msg_type == "llm_chunk" {
|
||||||
{
|
{
|
||||||
let mut json = json;
|
let mut json = json;
|
||||||
@@ -605,27 +818,306 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
|||||||
}
|
}
|
||||||
let _ = state.stats_tx.send(json.to_string());
|
let _ = state.stats_tx.send(json.to_string());
|
||||||
}
|
}
|
||||||
}
|
} else if msg_type == "llm_done" {
|
||||||
}
|
// Vapautetaan solmu ja tarkistetaan task_id:n aitous
|
||||||
|
state.node_busy.lock().unwrap().remove(&node_id);
|
||||||
|
let valid_task = if let Some(tid) = json.get("task_id").and_then(|v| v.as_str()) {
|
||||||
|
state.pending_task_ids.lock().unwrap().remove(tid)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut json = json;
|
||||||
|
if let Some(obj) = json.as_object_mut() {
|
||||||
|
let model = obj.get("model").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let prompt = obj.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let response = obj.get("response").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let tok_gen = obj.get("tokens_generated").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let duration = obj.get("duration_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
let tok_s = obj.get("tokens_per_sec").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
|
||||||
// Yhteys katkesi — merkitään session päättyneeksi ja siivotaan
|
println!();
|
||||||
state.db.close_session(node_id);
|
println!("\x1b[35m━━━ Solmu {} ━━━ {} ━━━\x1b[0m", node_id, model);
|
||||||
{
|
println!(" Prompt: \x1b[33m\"{}\"\x1b[0m", prompt);
|
||||||
let mut conns = state.ip_connections.lock().unwrap();
|
println!(" Vastaus: \x1b[32m{}\x1b[0m", response);
|
||||||
if let Some(count) = conns.get_mut(&ip) {
|
println!(" {} tokenia | {:.0}ms | \x1b[36m{:.1} tok/s\x1b[0m", tok_gen, duration, tok_s);
|
||||||
*count = count.saturating_sub(1);
|
|
||||||
if *count == 0 {
|
state.db.increment_tasks(node_id);
|
||||||
conns.remove(&ip);
|
obj.insert("node_id".to_string(), serde_json::json!(node_id));
|
||||||
|
}
|
||||||
|
let _ = state.stats_tx.send(json.to_string());
|
||||||
|
|
||||||
|
let active_incentives = state.feature_flags.read().await.get("Insentiivit").copied().unwrap_or(false);
|
||||||
|
let ui_sync = state.feature_flags.read().await.get("Pelimerkkien UI-synkkaus").copied().unwrap_or(false);
|
||||||
|
let mut current_balance = 0;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut task_count = state.total_tasks.lock().unwrap();
|
||||||
|
*task_count += 1;
|
||||||
|
|
||||||
|
if active_incentives && valid_task {
|
||||||
|
let mut tokens = state.nodes_tokens.lock().unwrap();
|
||||||
|
let balance = tokens.entry(node_id).or_insert(0);
|
||||||
|
*balance += 20; // Palkkio: 20 Kipinä-merkkiä
|
||||||
|
current_balance = *balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if active_incentives && ui_sync {
|
||||||
|
if let Some(tx) = state.node_channels.read().await.get(&node_id) {
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "token_balance",
|
||||||
|
"balance": current_balance
|
||||||
|
});
|
||||||
|
let _ = tx.send(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast_stats(&state).await;
|
||||||
|
}
|
||||||
|
} else if msg_type == "llm_error" {
|
||||||
|
state.node_busy.lock().unwrap().remove(&node_id);
|
||||||
|
if let Some(tid) = json.get("task_id").and_then(|v| v.as_str()) {
|
||||||
|
state.pending_task_ids.lock().unwrap().remove(tid);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut json = json;
|
||||||
|
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 == "user_text" {
|
||||||
|
// Käyttäjän lähettämä teksti — broadcastataan pair_taskina ja llm_promptina
|
||||||
|
let text = json.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let task_type = json.get("task_type").and_then(|v| v.as_str()).unwrap_or("tokenize");
|
||||||
|
if !text.is_empty() {
|
||||||
|
let preview: String = text.chars().take(80).collect();
|
||||||
|
tracing::info!("Solmu {} lähetti oman tekstin ({}): \"{}\"", node_id, task_type, preview);
|
||||||
|
match task_type {
|
||||||
|
"tokenize" => {
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "single_tokenize",
|
||||||
|
"text": text,
|
||||||
|
});
|
||||||
|
let _ = state.stats_tx.send(msg.to_string());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// LLM-prompti: lähetetään VAIN valitulle mallille, ei kaikille (välttää turhaa ruuhkaa ja busy-tiloja)
|
||||||
|
let prompt = serde_json::json!({
|
||||||
|
"type": "llm_prompt",
|
||||||
|
"prompt": text,
|
||||||
|
"model": task_type,
|
||||||
|
});
|
||||||
|
let _ = state.stats_tx.send(prompt.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yhteys katkesi — merkitään session päättyneeksi ja siivotaan atomisesti
|
||||||
|
state.db.close_session(node_id);
|
||||||
{
|
{
|
||||||
state.node_ips.lock().unwrap().remove(&node_id);
|
// Lukitaan kaikki kerralla, jotta solmu ei ole osittain siivottu
|
||||||
}
|
let mut tasks = state.node_tasks.lock().unwrap();
|
||||||
{
|
let mut conns = state.ip_connections.lock().unwrap();
|
||||||
state.nodes_vram.lock().unwrap().remove(&node_id);
|
let mut ips = state.node_ips.lock().unwrap();
|
||||||
|
let mut vram = state.nodes_vram.lock().unwrap();
|
||||||
|
let mut busy = state.node_busy.lock().unwrap();
|
||||||
|
tasks.remove(&node_id);
|
||||||
|
busy.remove(&node_id);
|
||||||
|
if let Some(count) = conns.get_mut(&ip) {
|
||||||
|
*count = count.saturating_sub(1);
|
||||||
|
if *count == 0 { conns.remove(&ip); }
|
||||||
|
}
|
||||||
|
ips.remove(&node_id);
|
||||||
|
vram.remove(&node_id);
|
||||||
}
|
}
|
||||||
tracing::info!("Solmu {} ({}) poistui verkosta.", node_id, ip);
|
tracing::info!("Solmu {} ({}) poistui verkosta.", node_id, ip);
|
||||||
broadcast_stats(&state).await;
|
broadcast_stats(&state).await;
|
||||||
sender_task.abort();
|
sender_task.abort();
|
||||||
}
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ChatCompletionRequest {
|
||||||
|
model: String,
|
||||||
|
prompt: String,
|
||||||
|
task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ChatCompletionResponse {
|
||||||
|
response: String,
|
||||||
|
model: String,
|
||||||
|
tokens_generated: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_chat_completions(
|
||||||
|
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
axum::Json(payload): axum::Json<ChatCompletionRequest>,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
// Rate limiting: max 10 pyyntöä per IP per minuutti
|
||||||
|
{
|
||||||
|
let mut limits = state.api_rate_limits.lock().unwrap();
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let entry = limits.entry(addr.ip()).or_insert((now, 0));
|
||||||
|
if now.duration_since(entry.0).as_secs() >= 60 {
|
||||||
|
*entry = (now, 1); // Uusi ikkuna
|
||||||
|
} else {
|
||||||
|
entry.1 += 1;
|
||||||
|
if entry.1 > 10 {
|
||||||
|
return (axum::http::StatusCode::TOO_MANY_REQUESTS, "Liian monta pyyntöä — yritä minuutin kuluttua").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Etsitään vapaa tai varattu solmu, joka vastaa pyydettyä mallia
|
||||||
|
let (target_node_free, target_node_any, total_matching) = {
|
||||||
|
let tasks = state.node_tasks.lock().unwrap();
|
||||||
|
let busy = state.node_busy.lock().unwrap();
|
||||||
|
let matching: Vec<u64> = tasks.iter().filter(|(_, task)| {
|
||||||
|
if payload.model == "qwen-coder" {
|
||||||
|
*task == "qwen-coder-05b" || *task == "qwen-coder"
|
||||||
|
} else {
|
||||||
|
**task == payload.model
|
||||||
|
}
|
||||||
|
}).map(|(k, _)| *k).collect();
|
||||||
|
let free = matching.iter().find(|id| !busy.contains(id)).copied();
|
||||||
|
let any = matching.first().copied();
|
||||||
|
(free, any, matching.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Broadcastataan reititystila UI:lle
|
||||||
|
let task_id = payload.task_id.clone();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merkitään solmu varatuksi ja task_id jaetuksi
|
||||||
|
state.node_busy.lock().unwrap().insert(target_node_id);
|
||||||
|
state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone());
|
||||||
|
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "llm_prompt",
|
||||||
|
"prompt": payload.prompt,
|
||||||
|
"model": payload.model,
|
||||||
|
"task_id": payload.task_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Odotuskanava valmiiksi (solmu palauttaa tuloksen stats_tx kautta)
|
||||||
|
let mut rx = state.stats_tx.subscribe();
|
||||||
|
|
||||||
|
// Kohdennettu reititys: lähetetään AI-tehtävä suoraan VAIN valitulle solmulle
|
||||||
|
{
|
||||||
|
let channels = state.node_channels.read().await;
|
||||||
|
if let Some(tx) = channels.get(&target_node_id) {
|
||||||
|
let _ = tx.send(msg.to_string());
|
||||||
|
tracing::info!("Reititettiin API-pyyntö solmulle {} (Malli: {})", target_node_id, payload.model);
|
||||||
|
} else {
|
||||||
|
return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Verkkovirhe: solmun yhteys katkesi reitityksen aikana").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = tokio::time::timeout(std::time::Duration::from_secs(120), async move {
|
||||||
|
loop {
|
||||||
|
let msg_str = match rx.recv().await {
|
||||||
|
Ok(msg) => msg,
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::debug!("API-kanava lagged {} viestiä", n);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => return Ok(None), // Kanava suljettu
|
||||||
|
};
|
||||||
|
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 Ok(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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if v["type"].as_str() == Some("llm_error") {
|
||||||
|
if let Some(tid) = v["task_id"].as_str() {
|
||||||
|
if tid == payload.task_id {
|
||||||
|
return Err(v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Ok(None)
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
match timeout {
|
||||||
|
Ok(Ok(Some(res))) => axum::Json(res).into_response(),
|
||||||
|
Ok(Ok(None)) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response(),
|
||||||
|
Ok(Err(err)) => (axum::http::StatusCode::CONFLICT, err).into_response(),
|
||||||
|
Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "native-node"
|
name = "native-node"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.36", features = ["full"] }
|
tokio = { version = "1.36", features = ["full"] }
|
||||||
@@ -12,5 +12,10 @@ serde_json = "1.0"
|
|||||||
sysinfo = "0.30"
|
sysinfo = "0.30"
|
||||||
nvml-wrapper = "0.10"
|
nvml-wrapper = "0.10"
|
||||||
wgpu = "24"
|
wgpu = "24"
|
||||||
|
candle-core = { version = "0.8", features = ["cuda"] }
|
||||||
|
candle-nn = "0.8"
|
||||||
|
candle-transformers = "0.8"
|
||||||
|
hf-hub = "0.4"
|
||||||
|
tokenizers = "0.19"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
297
network-poc/native-node/src/inference.rs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
use candle_core::{Device, Tensor, DType};
|
||||||
|
use candle_nn::VarBuilder;
|
||||||
|
use candle_transformers::models::qwen2::{Config as QwenConfig, ModelForCausalLM as QwenModel};
|
||||||
|
use hf_hub::{api::sync::Api, Repo, RepoType};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Top-k sampling with temperature and repetition penalty
|
||||||
|
fn sample_top_k(logits: &Tensor, k: usize, temperature: f64, generated_tokens: &[u32], repetition_penalty: f64, rng_state: &mut u64) -> Result<u32, String> {
|
||||||
|
let mut logits_vec: Vec<f32> = logits.to_vec1::<f32>().map_err(|e| format!("to_vec1: {}", e))?;
|
||||||
|
if logits_vec.is_empty() { return Err("Tyhjä logits".to_string()); }
|
||||||
|
|
||||||
|
// Repetition penalty: rankaisee jo generoituja tokeneita
|
||||||
|
for &token_id in generated_tokens {
|
||||||
|
if (token_id as usize) < logits_vec.len() {
|
||||||
|
let logit = &mut logits_vec[token_id as usize];
|
||||||
|
if *logit > 0.0 {
|
||||||
|
*logit /= repetition_penalty as f32;
|
||||||
|
} else {
|
||||||
|
*logit *= repetition_penalty as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temperature scaling
|
||||||
|
if temperature > 0.0 && temperature != 1.0 {
|
||||||
|
for logit in logits_vec.iter_mut() {
|
||||||
|
*logit /= temperature as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-k: etsitään k suurinta
|
||||||
|
let mut indexed: Vec<(usize, f32)> = logits_vec.iter().enumerate().map(|(i, &v)| (i, v)).collect();
|
||||||
|
indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
indexed.truncate(k);
|
||||||
|
|
||||||
|
if k == 1 || temperature == 0.0 {
|
||||||
|
return Ok(indexed[0].0 as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Softmax top-k:lle
|
||||||
|
let max_logit = indexed[0].1;
|
||||||
|
let exps: Vec<f32> = indexed.iter().map(|x| (x.1 - max_logit).exp()).collect();
|
||||||
|
let sum: f32 = exps.iter().sum();
|
||||||
|
let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
|
||||||
|
|
||||||
|
// XorShift64 RNG
|
||||||
|
*rng_state ^= *rng_state << 13;
|
||||||
|
*rng_state ^= *rng_state >> 7;
|
||||||
|
*rng_state ^= *rng_state << 17;
|
||||||
|
let rand_val = (*rng_state % 10000) as f32 / 10000.0;
|
||||||
|
|
||||||
|
let mut cumulative = 0.0;
|
||||||
|
for (i, p) in probs.iter().enumerate() {
|
||||||
|
cumulative += p;
|
||||||
|
if rand_val < cumulative {
|
||||||
|
return Ok(indexed[i].0 as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(indexed[0].0 as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LlmEngine {
|
||||||
|
tokenizer: tokenizers::Tokenizer,
|
||||||
|
model: QwenModel,
|
||||||
|
device: Device,
|
||||||
|
eos_token: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmEngine {
|
||||||
|
pub fn load() -> Result<Self, String> {
|
||||||
|
let device = Device::cuda_if_available(0).map_err(|e| format!("Device: {}", e))?;
|
||||||
|
let device_name = if device.is_cuda() { "CUDA" } else { "CPU" };
|
||||||
|
tracing::info!("LLM device: {}", device_name);
|
||||||
|
|
||||||
|
let dtype = if device.is_cuda() { DType::F16 } else { DType::F32 };
|
||||||
|
|
||||||
|
tracing::info!("Ladataan Qwen2.5-Coder-0.5B-Instruct...");
|
||||||
|
let api = Api::new().map_err(|e| format!("HF API: {}", e))?;
|
||||||
|
let repo = api.repo(Repo::with_revision(
|
||||||
|
"Qwen/Qwen2.5-Coder-0.5B-Instruct".to_string(),
|
||||||
|
RepoType::Model,
|
||||||
|
"main".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let tokenizer_path = repo.get("tokenizer.json").map_err(|e| format!("Tokenizer lataus: {}", e))?;
|
||||||
|
let model_path = repo.get("model.safetensors").map_err(|e| format!("Malli lataus: {}", e))?;
|
||||||
|
|
||||||
|
tracing::info!("Ladataan tokenizer: {:?}", tokenizer_path);
|
||||||
|
let tokenizer = tokenizers::Tokenizer::from_file(&tokenizer_path)
|
||||||
|
.map_err(|e| format!("Tokenizer: {}", e))?;
|
||||||
|
|
||||||
|
let config = QwenConfig {
|
||||||
|
vocab_size: 151936,
|
||||||
|
hidden_size: 896,
|
||||||
|
intermediate_size: 4864,
|
||||||
|
num_hidden_layers: 24,
|
||||||
|
num_attention_heads: 14,
|
||||||
|
num_key_value_heads: 2,
|
||||||
|
max_position_embeddings: 32768,
|
||||||
|
sliding_window: 32768,
|
||||||
|
max_window_layers: 21,
|
||||||
|
tie_word_embeddings: true,
|
||||||
|
rope_theta: 1000000.0,
|
||||||
|
rms_norm_eps: 1e-6,
|
||||||
|
use_sliding_window: false,
|
||||||
|
hidden_act: candle_nn::Activation::Silu,
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let vb = unsafe {
|
||||||
|
VarBuilder::from_mmaped_safetensors(&[model_path.clone()], dtype, &device)
|
||||||
|
.map_err(|e| format!("VarBuilder: {}", e))?
|
||||||
|
};
|
||||||
|
let model = QwenModel::new(&config, vb).map_err(|e| format!("Malli: {}", e))?;
|
||||||
|
tracing::info!("Malli ladattu ({:.1}s) — {}", start.elapsed().as_secs_f64(), device_name);
|
||||||
|
|
||||||
|
Ok(LlmEngine {
|
||||||
|
tokenizer,
|
||||||
|
model,
|
||||||
|
device,
|
||||||
|
eos_token: 151645,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(&mut self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> {
|
||||||
|
// 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)
|
||||||
|
.map_err(|e| format!("Encode: {}", e))?;
|
||||||
|
let input_ids: Vec<u32> = encoding.get_ids().to_vec();
|
||||||
|
let input_len = input_ids.len();
|
||||||
|
|
||||||
|
// Nollataan KV-cache edellisestä promptista
|
||||||
|
self.model.clear_kv_cache();
|
||||||
|
|
||||||
|
// Sampling-parametrit
|
||||||
|
let temperature = 0.7;
|
||||||
|
let top_k = 40;
|
||||||
|
let repetition_penalty = 1.15;
|
||||||
|
let mut rng_state: u64 = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos() as u64;
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Prefill
|
||||||
|
let input = Tensor::new(input_ids.as_slice(), &self.device)
|
||||||
|
.and_then(|t| t.unsqueeze(0))
|
||||||
|
.map_err(|e| format!("Tensor: {}", e))?;
|
||||||
|
|
||||||
|
let logits = self.model.forward(&input, 0)
|
||||||
|
.map_err(|e| format!("Forward prefill: {}", e))?;
|
||||||
|
|
||||||
|
let logits = logits.squeeze(0).map_err(|e| format!("Squeeze: {}", e))?;
|
||||||
|
let logits = if logits.dims().len() == 2 {
|
||||||
|
let seq_len = logits.dim(0).map_err(|e| format!("Dim: {}", e))?;
|
||||||
|
if seq_len == 0 { return Err("Tyhjä tensori".to_string()); }
|
||||||
|
logits.get(seq_len - 1).map_err(|e| format!("Get: {}", e))?
|
||||||
|
} else {
|
||||||
|
logits
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut generated_text = String::new();
|
||||||
|
let mut tokens_generated: usize = 0;
|
||||||
|
let mut all_tokens: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
let mut next_token = sample_top_k(&logits, top_k, temperature, &all_tokens, repetition_penalty, &mut rng_state)?;
|
||||||
|
|
||||||
|
if next_token != self.eos_token {
|
||||||
|
if let Ok(text) = self.tokenizer.decode(&[next_token], true) {
|
||||||
|
generated_text.push_str(&text);
|
||||||
|
}
|
||||||
|
all_tokens.push(next_token);
|
||||||
|
tokens_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoregressive
|
||||||
|
let mut pos = input_len;
|
||||||
|
for _ in 1..max_tokens {
|
||||||
|
if next_token == self.eos_token { break; }
|
||||||
|
|
||||||
|
let input = Tensor::new(&[next_token], &self.device)
|
||||||
|
.and_then(|t| t.unsqueeze(0))
|
||||||
|
.map_err(|e| format!("Tensor: {}", e))?;
|
||||||
|
|
||||||
|
let logits = self.model.forward(&input, pos)
|
||||||
|
.map_err(|e| format!("Forward pos {}: {}", pos, e))?;
|
||||||
|
|
||||||
|
let logits = logits.squeeze(0).map_err(|e| format!("Squeeze: {}", e))?;
|
||||||
|
let logits = if logits.dims().len() == 2 {
|
||||||
|
let seq_len = logits.dim(0).map_err(|e| format!("Dim: {}", e))?;
|
||||||
|
if seq_len == 0 { break; }
|
||||||
|
logits.get(seq_len - 1).map_err(|e| format!("Get: {}", e))?
|
||||||
|
} else {
|
||||||
|
logits
|
||||||
|
};
|
||||||
|
next_token = sample_top_k(&logits, top_k, temperature, &all_tokens, repetition_penalty, &mut rng_state)?;
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
if next_token == self.eos_token { break; }
|
||||||
|
|
||||||
|
if let Ok(text) = self.tokenizer.decode(&[next_token], true) {
|
||||||
|
generated_text.push_str(&text);
|
||||||
|
|
||||||
|
// Stop-sekvenssit: katkaistaan kun malli alkaa selittää
|
||||||
|
let lower = generated_text.to_lowercase();
|
||||||
|
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", "\n// Example", "\n// example", "\n# Example", "\n# example"] {
|
||||||
|
if let Some(pos) = generated_text.find(stop) {
|
||||||
|
generated_text.truncate(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all_tokens.push(next_token);
|
||||||
|
tokens_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gen_time = start.elapsed();
|
||||||
|
let tokens_per_sec = if gen_time.as_secs_f64() > 0.0 {
|
||||||
|
tokens_generated as f64 / gen_time.as_secs_f64()
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
Ok(GenerateResult {
|
||||||
|
text: strip_markdown_wrapper(&generated_text),
|
||||||
|
tokens_generated,
|
||||||
|
duration_ms: gen_time.as_millis() as f64,
|
||||||
|
tokens_per_sec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 text: String,
|
||||||
|
pub tokens_generated: usize,
|
||||||
|
pub duration_ms: f64,
|
||||||
|
pub tokens_per_sec: f64,
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ use sysinfo::System;
|
|||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
mod inference;
|
||||||
|
|
||||||
/// GPU-tietorakenne — yhtenäinen kaikille valmistajille
|
/// GPU-tietorakenne — yhtenäinen kaikille valmistajille
|
||||||
struct GpuInfo {
|
struct GpuInfo {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -225,6 +227,7 @@ fn build_auth_message(allocated_gb: u32) -> String {
|
|||||||
"status": "agent_ready",
|
"status": "agent_ready",
|
||||||
"node_type": "native",
|
"node_type": "native",
|
||||||
"allocated_gb": allocated_gb,
|
"allocated_gb": allocated_gb,
|
||||||
|
"selected_task": "qwen-coder-05b",
|
||||||
"system": sys,
|
"system": sys,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,7 +285,20 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yhdistetään hubiin — yritetään uudelleen katkon sattuessa
|
// Ladataan LLM-malli
|
||||||
|
tracing::info!("Ladataan LLM-mallia...");
|
||||||
|
let mut llm = match inference::LlmEngine::load() {
|
||||||
|
Ok(engine) => {
|
||||||
|
tracing::info!("LLM valmis inferenssiin!");
|
||||||
|
Some(engine)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("LLM-lataus epäonnistui: {} — toimitaan ilman inferenssiä", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Yhdistetään hubiin
|
||||||
loop {
|
loop {
|
||||||
match connect_async(&hub_url).await {
|
match connect_async(&hub_url).await {
|
||||||
Ok((ws_stream, _)) => {
|
Ok((ws_stream, _)) => {
|
||||||
@@ -295,17 +311,56 @@ async fn main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut busy = false;
|
||||||
|
|
||||||
while let Some(Ok(msg)) = read.next().await {
|
while let Some(Ok(msg)) = read.next().await {
|
||||||
if let Message::Text(text) = msg {
|
if let Message::Text(text) = msg {
|
||||||
if text.contains("pair_task") || text.contains("ai_task") {
|
// LLM-promptit
|
||||||
tracing::debug!("Tehtävä vastaanotettu: {}", &text[..text.len().min(80)]);
|
if text.contains("llm_prompt") && !busy {
|
||||||
let reply = json!({
|
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
"type": "result",
|
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
"status": "success",
|
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
"data": "native-node: ei vielä laskentaa"
|
let msg_model = task.get("model").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
});
|
|
||||||
let _ = write.send(Message::Text(reply.to_string())).await;
|
if !prompt.is_empty() && msg_model.starts_with("qwen-coder") {
|
||||||
|
|
||||||
|
if let Some(ref mut engine) = llm {
|
||||||
|
busy = true;
|
||||||
|
tracing::info!("Generoidaan (task_id: {}): \"{}\"", task_id, prompt);
|
||||||
|
|
||||||
|
match engine.generate(prompt, 64) {
|
||||||
|
Ok(result) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Tulos: {} tokenia | {:.0}ms | {:.1} tok/s | \"{}\"",
|
||||||
|
result.tokens_generated,
|
||||||
|
result.duration_ms,
|
||||||
|
result.tokens_per_sec,
|
||||||
|
&result.text[..result.text.len().min(80)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let done = json!({
|
||||||
|
"type": "llm_done",
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": "Qwen2.5-Coder-0.5B (native/GPU)",
|
||||||
|
"response": result.text,
|
||||||
|
"tokens_generated": result.tokens_generated,
|
||||||
|
"duration_ms": result.duration_ms,
|
||||||
|
"tokens_per_sec": (result.tokens_per_sec * 10.0).round() / 10.0,
|
||||||
|
"load_time_ms": 0,
|
||||||
|
"task_id": task_id,
|
||||||
|
});
|
||||||
|
let _ = write.send(Message::Text(done.to_string())).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Inferenssivirhe: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Ohitetaan pair_task, stats jne.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
|
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "node"
|
name = "node"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
@@ -17,6 +17,12 @@ web-sys = { version = "0.3.68", features = [
|
|||||||
"MessageEvent",
|
"MessageEvent",
|
||||||
"Performance",
|
"Performance",
|
||||||
"console",
|
"console",
|
||||||
|
"Request",
|
||||||
|
"RequestInit",
|
||||||
|
"Response",
|
||||||
|
"Headers",
|
||||||
|
"ReadableStream",
|
||||||
|
"ReadableStreamDefaultReader",
|
||||||
] }
|
] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -29,4 +35,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
|||||||
tokenizers = { version = "0.19.1", default-features = false, features = ["unstable_wasm"] }
|
tokenizers = { version = "0.19.1", default-features = false, features = ["unstable_wasm"] }
|
||||||
rexie = "0.6"
|
rexie = "0.6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
candle-core = { version = "0.8" }
|
||||||
|
candle-nn = "0.8"
|
||||||
|
candle-transformers = "0.8"
|
||||||
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
|
||||||
|
|||||||
BIN
network-poc/node/nodes.db
Normal file
118
network-poc/node/src/burn_smollm/attention.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use burn::module::{Module, Param};
|
||||||
|
use burn::tensor::{backend::Backend, Tensor};
|
||||||
|
use super::rope::RoPE;
|
||||||
|
use super::config::SmolLMConfig;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct KVCache<B: Backend> {
|
||||||
|
pub k: Tensor<B, 4>,
|
||||||
|
pub v: Tensor<B, 4>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Module, Debug)]
|
||||||
|
pub struct Attention<B: Backend> {
|
||||||
|
pub q_proj: Param<Tensor<B, 2>>, // [hidden, num_heads * head_dim]
|
||||||
|
pub k_proj: Param<Tensor<B, 2>>, // [hidden, num_kv_heads * head_dim]
|
||||||
|
pub v_proj: Param<Tensor<B, 2>>, // [hidden, num_kv_heads * head_dim]
|
||||||
|
pub o_proj: Param<Tensor<B, 2>>, // [num_heads * head_dim, hidden]
|
||||||
|
|
||||||
|
num_heads: usize,
|
||||||
|
num_kv_heads: usize,
|
||||||
|
head_dim: usize,
|
||||||
|
|
||||||
|
rope: RoPE<B>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Backend> Attention<B> {
|
||||||
|
pub fn new(config: &SmolLMConfig, device: &B::Device) -> Self {
|
||||||
|
let head_dim = config.hidden_size / config.num_attention_heads;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
q_proj: Param::from_tensor(Tensor::zeros([config.hidden_size, config.num_attention_heads * head_dim], device)),
|
||||||
|
k_proj: Param::from_tensor(Tensor::zeros([config.hidden_size, config.num_key_value_heads * head_dim], device)),
|
||||||
|
v_proj: Param::from_tensor(Tensor::zeros([config.hidden_size, config.num_key_value_heads * head_dim], device)),
|
||||||
|
o_proj: Param::from_tensor(Tensor::zeros([config.num_attention_heads * head_dim, config.hidden_size], device)),
|
||||||
|
|
||||||
|
num_heads: config.num_attention_heads,
|
||||||
|
num_kv_heads: config.num_key_value_heads,
|
||||||
|
head_dim,
|
||||||
|
|
||||||
|
rope: RoPE::new(head_dim, config.max_position_embeddings, config.rope_theta, device),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(
|
||||||
|
&self,
|
||||||
|
x: Tensor<B, 3>,
|
||||||
|
offset: usize,
|
||||||
|
cache: Option<KVCache<B>>
|
||||||
|
) -> (Tensor<B, 3>, KVCache<B>) {
|
||||||
|
let [batch, seq_len, hidden_dim] = x.dims();
|
||||||
|
|
||||||
|
// Project Q, K, V: x @ W -> [batch, seq, proj_dim]
|
||||||
|
let q = x.clone().matmul(self.q_proj.val().unsqueeze());
|
||||||
|
let k = x.clone().matmul(self.k_proj.val().unsqueeze());
|
||||||
|
let v = x.matmul(self.v_proj.val().unsqueeze());
|
||||||
|
|
||||||
|
// Reshape: [batch, seq, heads, head_dim] -> [batch, heads, seq, head_dim]
|
||||||
|
let q = q.reshape([batch, seq_len, self.num_heads, self.head_dim]).swap_dims(1, 2);
|
||||||
|
let k = k.reshape([batch, seq_len, self.num_kv_heads, self.head_dim]).swap_dims(1, 2);
|
||||||
|
let v = v.reshape([batch, seq_len, self.num_kv_heads, self.head_dim]).swap_dims(1, 2);
|
||||||
|
|
||||||
|
// Apply RoPE
|
||||||
|
let q = self.rope.forward(q, offset);
|
||||||
|
let k = self.rope.forward(k, offset);
|
||||||
|
|
||||||
|
// KV cache
|
||||||
|
let (k, v) = if let Some(c) = cache {
|
||||||
|
(Tensor::cat(vec![c.k, k], 2), Tensor::cat(vec![c.v, v], 2))
|
||||||
|
} else {
|
||||||
|
(k, v)
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_cache = KVCache { k: k.clone(), v: v.clone() };
|
||||||
|
let kv_len = k.dims()[2];
|
||||||
|
|
||||||
|
// GQA: repeat K,V heads — [batch, kv_heads, kv_len, hd] -> [batch, num_heads, kv_len, hd]
|
||||||
|
let num_reps = self.num_heads / self.num_kv_heads;
|
||||||
|
let k = if num_reps > 1 {
|
||||||
|
let [b, kv_h, s, hd] = k.dims();
|
||||||
|
k.reshape([b, kv_h, 1, s, hd]).repeat_dim(2, num_reps).reshape([b, self.num_heads, s, hd])
|
||||||
|
} else { k };
|
||||||
|
let v = if num_reps > 1 {
|
||||||
|
let [b, kv_h, s, hd] = v.dims();
|
||||||
|
v.reshape([b, kv_h, 1, s, hd]).repeat_dim(2, num_reps).reshape([b, self.num_heads, s, hd])
|
||||||
|
} else { v };
|
||||||
|
|
||||||
|
// Attention: Q @ K^T / sqrt(d)
|
||||||
|
let scale = 1.0 / (self.head_dim as f64).sqrt();
|
||||||
|
let scores = q.matmul(k.swap_dims(2, 3)).mul_scalar(scale);
|
||||||
|
// scores: [batch, heads, seq_len, kv_len]
|
||||||
|
|
||||||
|
// Causal mask for prefill (seq_len > 1)
|
||||||
|
let scores = if seq_len > 1 {
|
||||||
|
let mask_data: Vec<f32> = (0..seq_len).flat_map(|i| {
|
||||||
|
(0..kv_len).map(move |j| {
|
||||||
|
if j > offset + i { f32::NEG_INFINITY } else { 0.0 }
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
let mask = Tensor::<B, 2>::from_data(
|
||||||
|
burn::tensor::TensorData::new(mask_data, [seq_len, kv_len]),
|
||||||
|
&scores.device()
|
||||||
|
).reshape([1, 1, seq_len, kv_len]);
|
||||||
|
scores + mask
|
||||||
|
} else {
|
||||||
|
scores
|
||||||
|
};
|
||||||
|
|
||||||
|
let attn_weights = burn::tensor::activation::softmax(scores, 3);
|
||||||
|
|
||||||
|
let context = attn_weights.matmul(v);
|
||||||
|
// [batch, heads, seq, hd] -> [batch, seq, heads*hd]
|
||||||
|
let context = context.swap_dims(1, 2).reshape([batch, seq_len, self.num_heads * self.head_dim]);
|
||||||
|
|
||||||
|
let output = context.matmul(self.o_proj.val().unsqueeze());
|
||||||
|
|
||||||
|
(output, new_cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
network-poc/node/src/burn_smollm/config.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SmolLMConfig {
|
||||||
|
pub hidden_size: usize,
|
||||||
|
pub intermediate_size: usize,
|
||||||
|
pub vocab_size: usize,
|
||||||
|
pub num_hidden_layers: usize,
|
||||||
|
pub num_attention_heads: usize,
|
||||||
|
pub num_key_value_heads: usize,
|
||||||
|
pub rms_norm_eps: f64,
|
||||||
|
pub rope_theta: f32,
|
||||||
|
pub max_position_embeddings: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SmolLMConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hidden_size: 576,
|
||||||
|
intermediate_size: 1536,
|
||||||
|
vocab_size: 49152,
|
||||||
|
num_hidden_layers: 30,
|
||||||
|
num_attention_heads: 9,
|
||||||
|
num_key_value_heads: 3,
|
||||||
|
rms_norm_eps: 1e-5,
|
||||||
|
rope_theta: 10000.0,
|
||||||
|
max_position_embeddings: 2048,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
network-poc/node/src/burn_smollm/loader.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use burn::tensor::{backend::Backend, Tensor, TensorData};
|
||||||
|
use candle_core::safetensors;
|
||||||
|
use candle_core::Device as CandleDevice;
|
||||||
|
use burn::module::Param;
|
||||||
|
use super::model::LlamaModel;
|
||||||
|
use super::config::SmolLMConfig;
|
||||||
|
|
||||||
|
fn load_tensor_2d<B: Backend>(
|
||||||
|
tensors_map: &std::collections::HashMap<String, candle_core::Tensor>,
|
||||||
|
name: &str,
|
||||||
|
device: &B::Device,
|
||||||
|
shape_out_in: [usize; 2]
|
||||||
|
) -> Result<Param<Tensor<B, 2>>, String> {
|
||||||
|
let t = tensors_map.get(name).ok_or_else(|| format!("Puuttuu: {}", name))?;
|
||||||
|
let t = t.to_dtype(candle_core::DType::F32).unwrap();
|
||||||
|
let vec = t.flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||||
|
let t_burn = Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(vec, shape_out_in), device);
|
||||||
|
// transpose from [out, in] to [in, out]
|
||||||
|
Ok(Param::from_tensor(t_burn.transpose()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_tensor_1d<B: Backend>(
|
||||||
|
tensors_map: &std::collections::HashMap<String, candle_core::Tensor>,
|
||||||
|
name: &str,
|
||||||
|
device: &B::Device,
|
||||||
|
_shape: [usize; 1]
|
||||||
|
) -> Result<Param<Tensor<B, 1>>, String> {
|
||||||
|
let t = tensors_map.get(name).ok_or_else(|| format!("Puuttuu: {}", name))?;
|
||||||
|
let t = t.to_dtype(candle_core::DType::F32).unwrap();
|
||||||
|
let vec = t.flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||||
|
Ok(Param::from_tensor(Tensor::<B, 1>::from_floats(vec.as_slice(), device)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_embed<B: Backend>(
|
||||||
|
tensors_map: &std::collections::HashMap<String, candle_core::Tensor>,
|
||||||
|
name: &str,
|
||||||
|
device: &B::Device,
|
||||||
|
shape: [usize; 2]
|
||||||
|
) -> Result<Param<Tensor<B, 2>>, String> {
|
||||||
|
let t = tensors_map.get(name).ok_or_else(|| format!("Puuttuu: {}", name))?;
|
||||||
|
let t = t.to_dtype(candle_core::DType::F32).unwrap();
|
||||||
|
let vec = t.flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||||
|
// Embed ei transponoi samalla tavalla, se pysyy [vocab, hidden]
|
||||||
|
Ok(Param::from_tensor(Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(vec, shape), device)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_safetensors_to_model<B: Backend>(
|
||||||
|
buffer: &[u8],
|
||||||
|
config: &SmolLMConfig,
|
||||||
|
device: &B::Device
|
||||||
|
) -> Result<LlamaModel<B>, String> {
|
||||||
|
|
||||||
|
let mut model = LlamaModel::new(config, device);
|
||||||
|
let tensors_map = safetensors::load_buffer(buffer, &CandleDevice::Cpu)
|
||||||
|
.map_err(|e| format!("Virhe Safetensors luennassa: {}", e))?;
|
||||||
|
|
||||||
|
// Embeddings
|
||||||
|
model.embed_tokens = load_embed(&tensors_map, "model.embed_tokens.weight", device, [config.vocab_size, config.hidden_size])?;
|
||||||
|
model.norm.weight = load_tensor_1d(&tensors_map, "model.norm.weight", device, [config.hidden_size])?;
|
||||||
|
model.lm_head = load_embed(&tensors_map, "lm_head.weight", device, [config.vocab_size, config.hidden_size]).or_else(|_| {
|
||||||
|
load_embed(&tensors_map, "model.embed_tokens.weight", device, [config.vocab_size, config.hidden_size])
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let head_dim = config.hidden_size / config.num_attention_heads;
|
||||||
|
|
||||||
|
for i in 0..config.num_hidden_layers {
|
||||||
|
let prefix = format!("model.layers.{}", i);
|
||||||
|
|
||||||
|
let layer = &mut model.layers[i];
|
||||||
|
|
||||||
|
// Norms
|
||||||
|
layer.input_layernorm.weight = load_tensor_1d(&tensors_map, &format!("{}.input_layernorm.weight", prefix), device, [config.hidden_size])?;
|
||||||
|
layer.post_attention_layernorm.weight = load_tensor_1d(&tensors_map, &format!("{}.post_attention_layernorm.weight", prefix), device, [config.hidden_size])?;
|
||||||
|
|
||||||
|
// Attention
|
||||||
|
let num_heads = config.num_attention_heads;
|
||||||
|
let num_kv_heads = config.num_key_value_heads;
|
||||||
|
layer.self_attn.q_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.q_proj.weight", prefix), device, [num_heads * head_dim, config.hidden_size])?;
|
||||||
|
layer.self_attn.k_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.k_proj.weight", prefix), device, [num_kv_heads * head_dim, config.hidden_size])?;
|
||||||
|
layer.self_attn.v_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.v_proj.weight", prefix), device, [num_kv_heads * head_dim, config.hidden_size])?;
|
||||||
|
layer.self_attn.o_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.o_proj.weight", prefix), device, [config.hidden_size, num_heads * head_dim])?;
|
||||||
|
|
||||||
|
// MLP
|
||||||
|
layer.mlp.gate_proj = load_tensor_2d(&tensors_map, &format!("{}.mlp.gate_proj.weight", prefix), device, [config.intermediate_size, config.hidden_size])?;
|
||||||
|
layer.mlp.up_proj = load_tensor_2d(&tensors_map, &format!("{}.mlp.up_proj.weight", prefix), device, [config.intermediate_size, config.hidden_size])?;
|
||||||
|
layer.mlp.down_proj = load_tensor_2d(&tensors_map, &format!("{}.mlp.down_proj.weight", prefix), device, [config.hidden_size, config.intermediate_size])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
6
network-poc/node/src/burn_smollm/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod attention;
|
||||||
|
pub mod config;
|
||||||
|
pub mod loader;
|
||||||
|
pub mod model;
|
||||||
|
pub mod modules;
|
||||||
|
pub mod rope;
|
||||||
96
network-poc/node/src/burn_smollm/model.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use burn::module::{Module, Param};
|
||||||
|
use burn::tensor::{backend::Backend, Tensor, Int};
|
||||||
|
use super::modules::{RmsNorm, Mlp};
|
||||||
|
use super::attention::{Attention, KVCache};
|
||||||
|
use super::config::SmolLMConfig;
|
||||||
|
|
||||||
|
#[derive(Module, Debug)]
|
||||||
|
pub struct LlamaBlock<B: Backend> {
|
||||||
|
pub self_attn: Attention<B>,
|
||||||
|
pub mlp: Mlp<B>,
|
||||||
|
pub input_layernorm: RmsNorm<B>,
|
||||||
|
pub post_attention_layernorm: RmsNorm<B>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Backend> LlamaBlock<B> {
|
||||||
|
pub fn new(config: &SmolLMConfig, device: &B::Device) -> Self {
|
||||||
|
Self {
|
||||||
|
self_attn: Attention::new(config, device),
|
||||||
|
mlp: Mlp::new(config.hidden_size, config.intermediate_size, device),
|
||||||
|
input_layernorm: RmsNorm::new(config.hidden_size, config.rms_norm_eps, device),
|
||||||
|
post_attention_layernorm: RmsNorm::new(config.hidden_size, config.rms_norm_eps, device),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(
|
||||||
|
&self,
|
||||||
|
x: Tensor<B, 3>,
|
||||||
|
offset: usize,
|
||||||
|
cache: Option<KVCache<B>>
|
||||||
|
) -> (Tensor<B, 3>, KVCache<B>) {
|
||||||
|
let residual = x.clone();
|
||||||
|
let x_norm = self.input_layernorm.forward(x);
|
||||||
|
|
||||||
|
let (attn_out, new_cache) = self.self_attn.forward(x_norm, offset, cache);
|
||||||
|
|
||||||
|
let x = residual + attn_out;
|
||||||
|
|
||||||
|
let residual = x.clone();
|
||||||
|
let x_norm = self.post_attention_layernorm.forward(x);
|
||||||
|
let mlp_out = self.mlp.forward(x_norm);
|
||||||
|
|
||||||
|
let x = residual + mlp_out;
|
||||||
|
(x, new_cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Module, Debug)]
|
||||||
|
pub struct LlamaModel<B: Backend> {
|
||||||
|
pub embed_tokens: Param<Tensor<B, 2>>,
|
||||||
|
pub layers: Vec<LlamaBlock<B>>,
|
||||||
|
pub norm: RmsNorm<B>,
|
||||||
|
pub lm_head: Param<Tensor<B, 2>>, // For tie_word_embeddings this can point to embed_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Backend> LlamaModel<B> {
|
||||||
|
pub fn new(config: &SmolLMConfig, device: &B::Device) -> Self {
|
||||||
|
let embed = Tensor::zeros([config.vocab_size, config.hidden_size], device);
|
||||||
|
let lm_head = Tensor::zeros([config.vocab_size, config.hidden_size], device);
|
||||||
|
|
||||||
|
let mut layers = Vec::new();
|
||||||
|
for _ in 0..config.num_hidden_layers {
|
||||||
|
layers.push(LlamaBlock::new(config, device));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
embed_tokens: Param::from_tensor(embed),
|
||||||
|
layers,
|
||||||
|
norm: RmsNorm::new(config.hidden_size, config.rms_norm_eps, device),
|
||||||
|
lm_head: Param::from_tensor(lm_head),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(
|
||||||
|
&self,
|
||||||
|
input_ids: Tensor<B, 2, Int>,
|
||||||
|
offset: usize,
|
||||||
|
caches: &mut Vec<Option<KVCache<B>>>
|
||||||
|
) -> Tensor<B, 3> {
|
||||||
|
let [_batch, _seq_len] = input_ids.dims();
|
||||||
|
|
||||||
|
let mut x = burn::tensor::module::embedding(self.embed_tokens.val(), input_ids);
|
||||||
|
|
||||||
|
for (i, layer) in self.layers.iter().enumerate() {
|
||||||
|
let cache = caches[i].take();
|
||||||
|
let (out, new_cache) = layer.forward(x, offset, cache);
|
||||||
|
x = out;
|
||||||
|
caches[i] = Some(new_cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
x = self.norm.forward(x);
|
||||||
|
|
||||||
|
// Matmul with lm_head (or embed_tokens if tied) to get logits
|
||||||
|
// Notice: lm_head is typically [vocab_size, hidden_size] in HF, so we swap dims
|
||||||
|
x.matmul(self.lm_head.val().swap_dims(0, 1).unsqueeze())
|
||||||
|
}
|
||||||
|
}
|
||||||
59
network-poc/node/src/burn_smollm/modules.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use burn::module::{Module, Param};
|
||||||
|
use burn::tensor::{backend::Backend, Tensor};
|
||||||
|
|
||||||
|
#[derive(Module, Debug)]
|
||||||
|
pub struct RmsNorm<B: Backend> {
|
||||||
|
pub weight: Param<Tensor<B, 1>>,
|
||||||
|
epsilon: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Backend> RmsNorm<B> {
|
||||||
|
pub fn new(size: usize, epsilon: f64, device: &B::Device) -> Self {
|
||||||
|
let weight = Param::from_tensor(Tensor::ones([size], device));
|
||||||
|
Self { weight, epsilon }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
|
||||||
|
// x: [batch, seq_len, dim]
|
||||||
|
// RMSNorm: x * weight / sqrt(mean(x^2) + eps)
|
||||||
|
let x_sq = x.clone().powf_scalar(2.0);
|
||||||
|
// mean over last dim, keeping dims for broadcast
|
||||||
|
let [b, s, d] = x_sq.dims();
|
||||||
|
let variance = x_sq.sum_dim(2).div_scalar(d as f32);
|
||||||
|
let norm = x.div(variance.add_scalar(self.epsilon).sqrt());
|
||||||
|
|
||||||
|
let w = self.weight.val().unsqueeze::<2>().unsqueeze::<3>().reshape([1, 1, d]);
|
||||||
|
norm * w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Module, Debug)]
|
||||||
|
pub struct Mlp<B: Backend> {
|
||||||
|
pub gate_proj: Param<Tensor<B, 2>>, // [in, intermediate]
|
||||||
|
pub up_proj: Param<Tensor<B, 2>>, // [in, intermediate]
|
||||||
|
pub down_proj: Param<Tensor<B, 2>>, // [intermediate, out]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Backend> Mlp<B> {
|
||||||
|
pub fn new(hidden_size: usize, intermediate_size: usize, device: &B::Device) -> Self {
|
||||||
|
Self {
|
||||||
|
gate_proj: Param::from_tensor(Tensor::zeros([hidden_size, intermediate_size], device)),
|
||||||
|
up_proj: Param::from_tensor(Tensor::zeros([hidden_size, intermediate_size], device)),
|
||||||
|
down_proj: Param::from_tensor(Tensor::zeros([intermediate_size, hidden_size], device)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
|
||||||
|
// x: [batch, seq, hidden]
|
||||||
|
// gate = x @ gate_proj -> [batch, seq, intermediate]
|
||||||
|
let gate = x.clone().matmul(self.gate_proj.val().unsqueeze());
|
||||||
|
let up = x.matmul(self.up_proj.val().unsqueeze());
|
||||||
|
|
||||||
|
// SiLU(gate) * up
|
||||||
|
let silu = gate.clone() * burn::tensor::activation::sigmoid(gate);
|
||||||
|
let intermediate = silu * up;
|
||||||
|
|
||||||
|
// intermediate @ down_proj -> [batch, seq, hidden]
|
||||||
|
intermediate.matmul(self.down_proj.val().unsqueeze())
|
||||||
|
}
|
||||||
|
}
|
||||||
59
network-poc/node/src/burn_smollm/rope.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use burn::module::Module;
|
||||||
|
use burn::tensor::{backend::Backend, Tensor};
|
||||||
|
|
||||||
|
#[derive(Module, Debug)]
|
||||||
|
pub struct RoPE<B: Backend> {
|
||||||
|
cos_cache: Tensor<B, 2>,
|
||||||
|
sin_cache: Tensor<B, 2>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Backend> RoPE<B> {
|
||||||
|
pub fn new(head_dim: usize, max_seq_len: usize, theta: f32, device: &B::Device) -> Self {
|
||||||
|
// (head_dim / 2) values
|
||||||
|
let half_dim = head_dim / 2;
|
||||||
|
let inv_freq: Vec<f32> = (0..half_dim)
|
||||||
|
.map(|i| 1.0 / theta.powf((2 * i) as f32 / head_dim as f32))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let inv_freq = Tensor::<B, 1>::from_floats(inv_freq.as_slice(), device).unsqueeze::<2>();
|
||||||
|
let t_floats: Vec<f32> = (0..max_seq_len).map(|v| v as f32).collect();
|
||||||
|
let t = Tensor::<B, 1>::from_floats(t_floats.as_slice(), device).unsqueeze::<2>().transpose();
|
||||||
|
// t shape: [max_seq_len, 1]
|
||||||
|
// inv_freq shape: [1, half_dim]
|
||||||
|
|
||||||
|
// freqs shape: [max_seq_len, half_dim]
|
||||||
|
let freqs = t.matmul(inv_freq);
|
||||||
|
|
||||||
|
let cos_cache = freqs.clone().cos();
|
||||||
|
let sin_cache = freqs.sin();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
cos_cache,
|
||||||
|
sin_cache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(&self, x: Tensor<B, 4>, offset: usize) -> Tensor<B, 4> {
|
||||||
|
let [batch, heads, seq_len, head_dim] = x.dims();
|
||||||
|
let half_dim = head_dim / 2;
|
||||||
|
|
||||||
|
// x shape: [batch, heads, seq_len, head_dim]
|
||||||
|
// valitaan viipaleet (x1 ja x2) jotta saadaan pyöritettyä rotaatiot
|
||||||
|
let x1 = x.clone().slice([0..batch, 0..heads, 0..seq_len, 0..half_dim]);
|
||||||
|
let x2 = x.clone().slice([0..batch, 0..heads, 0..seq_len, half_dim..head_dim]);
|
||||||
|
|
||||||
|
// haetaan vastaava seq offsetista alkaen
|
||||||
|
let cos = self.cos_cache.clone().slice([offset..offset+seq_len, 0..half_dim])
|
||||||
|
.unsqueeze::<4>() // [seq, half_dim, 1]
|
||||||
|
.reshape([1, 1, seq_len, half_dim]);
|
||||||
|
let sin = self.sin_cache.clone().slice([offset..offset+seq_len, 0..half_dim])
|
||||||
|
.reshape([1, 1, seq_len, half_dim]);
|
||||||
|
|
||||||
|
// x1 * cos - x2 * sin
|
||||||
|
let o1 = x1.clone().mul(cos.clone()) - x2.clone().mul(sin.clone());
|
||||||
|
// x2 * cos + x1 * sin
|
||||||
|
let o2 = x2.mul(cos) + x1.mul(sin);
|
||||||
|
|
||||||
|
Tensor::cat(vec![o1, o2], 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
use web_sys::{console, WebSocket, MessageEvent};
|
use web_sys::{WebSocket, MessageEvent};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::atomic::{AtomicU32, AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicU32, AtomicBool, Ordering};
|
||||||
@@ -7,15 +7,30 @@ use burn::tensor::Tensor;
|
|||||||
use burn::backend::{Wgpu, NdArray};
|
use burn::backend::{Wgpu, NdArray};
|
||||||
|
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod sampling;
|
||||||
|
pub mod smollm;
|
||||||
|
pub mod qwen;
|
||||||
|
pub mod qwen_coder;
|
||||||
|
pub mod phi3;
|
||||||
|
pub mod burn_smollm;
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
macro_rules! console_log {
|
macro_rules! console_log {
|
||||||
($($t:tt)*) => (console::log_1(&format_args!($($t)*).to_string().into()))
|
($($t:tt)*) => (web_sys::console::log_1(&format_args!($($t)*).to_string().into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Globaali muuttuja GPU Load Sliderille (25-100%)
|
|
||||||
static GPU_LOAD_PERCENT: AtomicU32 = AtomicU32::new(50);
|
static GPU_LOAD_PERCENT: AtomicU32 = AtomicU32::new(50);
|
||||||
// Onko WebGPU käytettävissä — asetetaan JS-puolelta käynnistyksessä
|
|
||||||
static HAS_WEBGPU: AtomicBool = AtomicBool::new(true);
|
static HAS_WEBGPU: AtomicBool = AtomicBool::new(true);
|
||||||
|
static SELECTED_TASK: AtomicU32 = AtomicU32::new(0);
|
||||||
|
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]
|
#[wasm_bindgen]
|
||||||
pub fn set_gpu_load(load: u32) {
|
pub fn set_gpu_load(load: u32) {
|
||||||
@@ -102,6 +117,31 @@ 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);
|
||||||
|
let preview: String = text.chars().take(50).collect();
|
||||||
|
console_log!("Tokenisaatio: \"{}\" → {} tokenia | {:.2} m/t | {:.2}ms",
|
||||||
|
preview, 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
|
/// 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>>) {
|
async fn run_pair_comparison(en_text: String, fi_text: String, ws: Rc<RefCell<WebSocket>>) {
|
||||||
let load_pct = GPU_LOAD_PERCENT.load(Ordering::SeqCst);
|
let load_pct = GPU_LOAD_PERCENT.load(Ordering::SeqCst);
|
||||||
@@ -148,12 +188,15 @@ async fn run_pair_comparison(en_text: String, fi_text: String, ws: Rc<RefCell<We
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_json: String) -> Result<(), JsValue> {
|
pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_json: String, task_id: u32) -> Result<(), JsValue> {
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
HAS_WEBGPU.store(has_webgpu, Ordering::SeqCst);
|
HAS_WEBGPU.store(has_webgpu, Ordering::SeqCst);
|
||||||
|
SELECTED_TASK.store(task_id, Ordering::SeqCst);
|
||||||
let backend_name = if has_webgpu { "WebGPU" } else { "CPU (NdArray)" };
|
let backend_name = if has_webgpu { "WebGPU" } else { "CPU (NdArray)" };
|
||||||
console_log!("Kipinä Agent Node käynnistyy — backend: {}", backend_name);
|
let task_names = ["tokenize", "smollm-135m", "qwen-05b", "phi3-mini", "qwen-coder-05b", "qwen-coder-3b"];
|
||||||
|
let task_name = task_names.get(task_id as usize).unwrap_or(&"tokenize");
|
||||||
|
console_log!("Kipinä Agent Node käynnistyy — backend: {} | tehtävä: {}", backend_name, task_name);
|
||||||
|
|
||||||
let device_info = device_info_json.clone();
|
let device_info = device_info_json.clone();
|
||||||
|
|
||||||
@@ -182,7 +225,11 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
|
|||||||
if let Ok(txt) = e.data().dyn_into::<js_sys::JsString>() {
|
if let Ok(txt) = e.data().dyn_into::<js_sys::JsString>() {
|
||||||
let msg: String = txt.into();
|
let msg: String = txt.into();
|
||||||
|
|
||||||
if msg.contains("pair_task") {
|
let current_task = SELECTED_TASK.load(Ordering::SeqCst);
|
||||||
|
let auto_on = AUTO_TASKS.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
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) {
|
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();
|
let en = task.get("en").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
let fi = task.get("fi").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let fi = task.get("fi").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
@@ -193,6 +240,90 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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ä
|
||||||
|
} else if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
|
||||||
|
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let model = task.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
if !prompt.is_empty() && model == "smollm-135m" {
|
||||||
|
LLM_BUSY.store(true, Ordering::SeqCst);
|
||||||
|
let ws_for_async = ws_clone.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
smollm::run_smollm_inference(prompt, ws_for_async).await;
|
||||||
|
LLM_BUSY.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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) {
|
||||||
|
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let model = task.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
if !prompt.is_empty() && model == "qwen-05b" {
|
||||||
|
LLM_BUSY.store(true, Ordering::SeqCst);
|
||||||
|
let ws_for_async = ws_clone.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
qwen::run_qwen_inference(prompt, ws_for_async).await;
|
||||||
|
LLM_BUSY.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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) {
|
||||||
|
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let model = task.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
if !prompt.is_empty() && model.starts_with("phi3-mini") {
|
||||||
|
LLM_BUSY.store(true, Ordering::SeqCst);
|
||||||
|
let ws_for_async = ws_clone.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
phi3::run_phi3_inference(prompt, ws_for_async).await;
|
||||||
|
LLM_BUSY.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if msg.contains("llm_prompt") && (current_task == 4 || current_task == 5) {
|
||||||
|
// Qwen2.5-Coder: 4 = 0.5B, 5 = 3B
|
||||||
|
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
|
||||||
|
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let model = task.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let task_id = task.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
|
||||||
|
if !prompt.is_empty() && model.starts_with("qwen-coder") {
|
||||||
|
if LLM_BUSY.load(Ordering::SeqCst) {
|
||||||
|
if let Some(tid) = task_id {
|
||||||
|
let err_msg = serde_json::json!({
|
||||||
|
"type": "llm_error",
|
||||||
|
"task_id": tid,
|
||||||
|
"error": "Solmu on paraikaa varattuna toisen tehtävän suorittamiseen"
|
||||||
|
});
|
||||||
|
let _ = ws_clone.borrow().send_with_str(&err_msg.to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let use_3b = current_task == 5;
|
||||||
|
LLM_BUSY.store(true, Ordering::SeqCst);
|
||||||
|
let ws_for_async = ws_clone.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
qwen_coder::run_coder_inference(prompt, ws_for_async, use_3b, task_id).await;
|
||||||
|
LLM_BUSY.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if msg.contains("ai_task") {
|
} else if msg.contains("ai_task") {
|
||||||
console_log!("Hub task vastaanotettu, ajetaan GPU:lla...");
|
console_log!("Hub task vastaanotettu, ajetaan GPU:lla...");
|
||||||
let ws_for_async = ws_clone.clone();
|
let ws_for_async = ws_clone.clone();
|
||||||
|
|||||||
36
network-poc/node/src/phi3.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use candle_core::{Device, Tensor, DType};
|
||||||
|
use candle_nn::VarBuilder;
|
||||||
|
use candle_transformers::models::phi3::{Config as Phi3Config, Model as Phi3Model};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use web_sys::WebSocket;
|
||||||
|
|
||||||
|
use crate::storage;
|
||||||
|
|
||||||
|
macro_rules! console_log {
|
||||||
|
($($t:tt)*) => (web_sys::console::log_1(&format_args!($($t)*).to_string().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_URL: &str = "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct/resolve/main/model.safetensors.index.json";
|
||||||
|
const TOKENIZER_URL: &str = "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct/resolve/main/tokenizer.json";
|
||||||
|
|
||||||
|
// Phi-3 Mini on iso (7.6 GB) — käytetään kvantisoidumpaa versiota myöhemmin
|
||||||
|
// Tällä hetkellä: placeholder joka raportoi koon ja jättää inferenssin väliin
|
||||||
|
pub async fn run_phi3_inference(prompt: String, ws: Rc<RefCell<WebSocket>>) {
|
||||||
|
console_log!("[Phi-3] Phi-3 Mini 3.8B on liian suuri selaimessa ajettavaksi (~7.6 GB).");
|
||||||
|
console_log!("[Phi-3] Käytä SmolLM 135M tai Qwen2.5 0.5B selaininferenssiin.");
|
||||||
|
console_log!("[Phi-3] Phi-3 tuetaan native-node:lla (Docker + GPU).");
|
||||||
|
|
||||||
|
let done = serde_json::json!({
|
||||||
|
"type": "llm_done",
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": "Phi-3-Mini (ei tuettu selaimessa)",
|
||||||
|
"response": "Phi-3 Mini 3.8B on liian suuri selaimessa ajettavaksi. Käytä SmolLM 135M tai Qwen2.5 0.5B.",
|
||||||
|
"tokens_generated": 0,
|
||||||
|
"duration_ms": 0,
|
||||||
|
"tokens_per_sec": 0,
|
||||||
|
"load_time_ms": 0,
|
||||||
|
});
|
||||||
|
let _ = ws.borrow().send_with_str(&done.to_string());
|
||||||
|
}
|
||||||
220
network-poc/node/src/qwen.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use candle_core::{Device, Tensor, DType};
|
||||||
|
use candle_nn::VarBuilder;
|
||||||
|
use candle_transformers::models::qwen2::{Config as QwenConfig, ModelForCausalLM as QwenModel};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use web_sys::WebSocket;
|
||||||
|
|
||||||
|
use crate::storage;
|
||||||
|
|
||||||
|
macro_rules! console_log {
|
||||||
|
($($t:tt)*) => (web_sys::console::log_1(&format_args!($($t)*).to_string().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_URL: &str = "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct/resolve/main/model.safetensors";
|
||||||
|
const TOKENIZER_URL: &str = "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct/resolve/main/tokenizer.json";
|
||||||
|
|
||||||
|
/// Streaming-lataus HuggingFacesta IndexedDB-cacheen
|
||||||
|
async fn ensure_cached(key: &str, url: &str, ws: &Rc<RefCell<WebSocket>>) -> Result<Vec<u8>, String> {
|
||||||
|
if let Ok(Some(bytes)) = storage::load_from_idb(key).await {
|
||||||
|
console_log!("[Qwen] {} löytyi välimuistista ({} MB)", key, bytes.len() / 1024 / 1024);
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
console_log!("[Qwen] Ladataan {}...", key);
|
||||||
|
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url))
|
||||||
|
.await.map_err(|e| format!("Fetch epäonnistui: {:?}", e))?;
|
||||||
|
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "Ei Response".to_string())?;
|
||||||
|
if !resp.ok() { return Err(format!("HTTP {}", resp.status())); }
|
||||||
|
|
||||||
|
let total_size: usize = resp.headers()
|
||||||
|
.get("content-length").ok().flatten()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let body = resp.body().ok_or("Ei bodyä")?;
|
||||||
|
let reader: web_sys::ReadableStreamDefaultReader = body.get_reader().dyn_into().map_err(|_| "Ei reader".to_string())?;
|
||||||
|
|
||||||
|
let mut data: Vec<u8> = Vec::with_capacity(total_size);
|
||||||
|
let mut last_pct: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let chunk = wasm_bindgen_futures::JsFuture::from(reader.read())
|
||||||
|
.await.map_err(|e| format!("Read: {:?}", e))?;
|
||||||
|
let done = js_sys::Reflect::get(&chunk, &"done".into()).ok().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
|
if done { break; }
|
||||||
|
let value = js_sys::Reflect::get(&chunk, &"value".into()).map_err(|_| "value puuttuu".to_string())?;
|
||||||
|
let array = js_sys::Uint8Array::new(&value);
|
||||||
|
let mut buf = vec![0u8; array.length() as usize];
|
||||||
|
array.copy_to(&mut buf);
|
||||||
|
data.extend_from_slice(&buf);
|
||||||
|
|
||||||
|
if total_size > 0 {
|
||||||
|
let pct = ((data.len() as f64 / total_size as f64) * 100.0) as u32;
|
||||||
|
if pct >= last_pct + 5 || pct == 100 {
|
||||||
|
last_pct = pct;
|
||||||
|
console_log!("[Qwen] {} lataus: {}%", key, pct);
|
||||||
|
let msg = serde_json::json!({ "type": "download_progress", "file": key, "pct": pct, "loaded_mb": data.len()/1024/1024, "total_mb": total_size/1024/1024 });
|
||||||
|
let _ = ws.borrow().send_with_str(&msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console_log!("[Qwen] Tallennetaan {} ({} MB)...", key, data.len() / 1024 / 1024);
|
||||||
|
let _ = storage::save_to_idb(key, &data).await;
|
||||||
|
console_log!("[Qwen] {} tallennettu!", key);
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_qwen_inference(prompt: String, ws: Rc<RefCell<WebSocket>>) {
|
||||||
|
let perf = web_sys::window().unwrap().performance().unwrap();
|
||||||
|
|
||||||
|
let tok_bytes = match ensure_cached("qwen05b-tokenizer.json", TOKENIZER_URL, &ws).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => { console_log!("[Qwen] Tokenizer-virhe: {}", e); return; }
|
||||||
|
};
|
||||||
|
let tokenizer = match tokenizers::Tokenizer::from_bytes(&tok_bytes) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => { console_log!("[Qwen] Tokenizer-parsinta: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let model_bytes = match ensure_cached("qwen05b-model.safetensors", MODEL_URL, &ws).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => { console_log!("[Qwen] Malli-virhe: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
console_log!("[Qwen] Rakennetaan mallia...");
|
||||||
|
let start_load = perf.now();
|
||||||
|
let device = Device::Cpu;
|
||||||
|
let dtype = DType::F32;
|
||||||
|
|
||||||
|
let tensors = match candle_core::safetensors::load_buffer(&model_bytes, &device) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => { console_log!("[Qwen] Safetensors: {}", e); return; }
|
||||||
|
};
|
||||||
|
let vb = VarBuilder::from_tensors(tensors, dtype, &device);
|
||||||
|
|
||||||
|
let config = QwenConfig {
|
||||||
|
vocab_size: 151936,
|
||||||
|
hidden_size: 896,
|
||||||
|
intermediate_size: 4864,
|
||||||
|
num_hidden_layers: 24,
|
||||||
|
num_attention_heads: 14,
|
||||||
|
num_key_value_heads: 2,
|
||||||
|
max_position_embeddings: 32768,
|
||||||
|
sliding_window: 32768,
|
||||||
|
max_window_layers: 21,
|
||||||
|
tie_word_embeddings: true,
|
||||||
|
rope_theta: 1000000.0,
|
||||||
|
rms_norm_eps: 1e-6,
|
||||||
|
use_sliding_window: false,
|
||||||
|
hidden_act: candle_nn::Activation::Silu,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut model = match QwenModel::new(&config, vb) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => { console_log!("[Qwen] Mallin lataus: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let load_time = perf.now() - start_load;
|
||||||
|
console_log!("[Qwen] Malli ladattu ({:.0}ms). Generoidaan...", load_time);
|
||||||
|
|
||||||
|
let encoding = match tokenizer.encode(prompt.as_str(), true) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => { console_log!("[Qwen] Tokenisointivirhe: {}", e); return; }
|
||||||
|
};
|
||||||
|
let input_ids: Vec<u32> = encoding.get_ids().to_vec();
|
||||||
|
let input_len = input_ids.len();
|
||||||
|
console_log!("[Qwen] Syöte: {} tokenia", input_len);
|
||||||
|
|
||||||
|
let start_gen = perf.now();
|
||||||
|
let max_new_tokens = 32;
|
||||||
|
let mut generated_text = String::new();
|
||||||
|
let mut tokens_generated: usize = 0;
|
||||||
|
|
||||||
|
// Prefill
|
||||||
|
let input = match Tensor::new(input_ids.as_slice(), &device).and_then(|t| t.unsqueeze(0)) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => { console_log!("[Qwen] Tensor: {}", e); return; }
|
||||||
|
};
|
||||||
|
let logits = match model.forward(&input, 0) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => { console_log!("[Qwen] Forward (prefill): {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward palauttaa [batch, vocab_size] tai [batch, seq_len, vocab_size]
|
||||||
|
let logits = logits.squeeze(0).unwrap();
|
||||||
|
let logits = if logits.dims().len() == 2 {
|
||||||
|
// [seq_len, vocab_size] — ota viimeinen
|
||||||
|
logits.get(logits.dim(0).unwrap() - 1).unwrap()
|
||||||
|
} else {
|
||||||
|
logits // jo [vocab_size]
|
||||||
|
};
|
||||||
|
let mut next_token = crate::sampling::sample_top_k(&logits, 10, 5.0);
|
||||||
|
console_log!("[Qwen] Ensimmäinen token: {}", next_token);
|
||||||
|
|
||||||
|
let eos_token = 151645u32; // <|endoftext|> for Qwen2.5
|
||||||
|
|
||||||
|
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-0.5B" });
|
||||||
|
let _ = ws.borrow().send_with_str(&chunk.to_string());
|
||||||
|
}
|
||||||
|
tokens_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoregressive
|
||||||
|
let mut pos = input_len;
|
||||||
|
for _ in 1..max_new_tokens {
|
||||||
|
if next_token == eos_token { break; }
|
||||||
|
|
||||||
|
let input = match Tensor::new(&[next_token], &device).and_then(|t| t.unsqueeze(0)) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => { console_log!("[Qwen] Tensor: {}", e); break; }
|
||||||
|
};
|
||||||
|
let logits = match model.forward(&input, pos) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => { console_log!("[Qwen] Forward pos {}: {}", pos, e); break; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let logits = logits.squeeze(0).unwrap();
|
||||||
|
let logits = if logits.dims().len() == 2 {
|
||||||
|
logits.get(logits.dim(0).unwrap() - 1).unwrap()
|
||||||
|
} else {
|
||||||
|
logits
|
||||||
|
};
|
||||||
|
next_token = crate::sampling::sample_top_k(&logits, 10, 5.0);
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
if next_token == eos_token { break; }
|
||||||
|
|
||||||
|
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-0.5B" });
|
||||||
|
let _ = ws.borrow().send_with_str(&chunk.to_string());
|
||||||
|
}
|
||||||
|
tokens_generated += 1;
|
||||||
|
crate::sleep_ms(0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gen_time = perf.now() - start_gen;
|
||||||
|
let tokens_per_sec = if gen_time > 0.0 { (tokens_generated as f64 / gen_time) * 1000.0 } else { 0.0 };
|
||||||
|
console_log!("[Qwen] {} tokenia | {:.0}ms | {:.1} tok/s", tokens_generated, gen_time, tokens_per_sec);
|
||||||
|
|
||||||
|
let done = serde_json::json!({
|
||||||
|
"type": "llm_done",
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": "Qwen2.5-0.5B-Instruct",
|
||||||
|
"response": generated_text,
|
||||||
|
"tokens_generated": tokens_generated,
|
||||||
|
"duration_ms": (gen_time * 100.0).round() / 100.0,
|
||||||
|
"tokens_per_sec": (tokens_per_sec * 10.0).round() / 10.0,
|
||||||
|
"load_time_ms": (load_time * 100.0).round() / 100.0,
|
||||||
|
});
|
||||||
|
let _ = ws.borrow().send_with_str(&done.to_string());
|
||||||
|
}
|
||||||
387
network-poc/node/src/qwen_coder.rs
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
use candle_core::{Device, Tensor, DType};
|
||||||
|
use candle_nn::VarBuilder;
|
||||||
|
use candle_transformers::models::qwen2::{Config as QwenConfig, ModelForCausalLM as QwenModel};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use web_sys::WebSocket;
|
||||||
|
|
||||||
|
use crate::storage;
|
||||||
|
|
||||||
|
macro_rules! console_log {
|
||||||
|
($($t:tt)*) => (web_sys::console::log_1(&format_args!($($t)*).to_string().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.5B — nopea, sopii kaikille laitteille
|
||||||
|
const MODEL_05B_URL: &str = "https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct/resolve/main/model.safetensors";
|
||||||
|
const TOKENIZER_05B_URL: &str = "https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct/resolve/main/tokenizer.json";
|
||||||
|
|
||||||
|
// 3B — parempi laatu, vaatii enemmän muistia (~6 GB lataus, ~12 GB RAM)
|
||||||
|
const MODEL_3B_PART1_URL: &str = "https://huggingface.co/Qwen/Qwen2.5-Coder-3B-Instruct/resolve/main/model-00001-of-00002.safetensors";
|
||||||
|
const MODEL_3B_PART2_URL: &str = "https://huggingface.co/Qwen/Qwen2.5-Coder-3B-Instruct/resolve/main/model-00002-of-00002.safetensors";
|
||||||
|
const TOKENIZER_3B_URL: &str = "https://huggingface.co/Qwen/Qwen2.5-Coder-3B-Instruct/resolve/main/tokenizer.json";
|
||||||
|
|
||||||
|
struct CachedModel {
|
||||||
|
model: QwenModel,
|
||||||
|
tokenizer: tokenizers::Tokenizer,
|
||||||
|
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! {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_cached(key: &str, url: &str, ws: &Rc<RefCell<WebSocket>>) -> Result<Rc<Vec<u8>>, String> {
|
||||||
|
// 1. Tarkistetaan RAM välimuisti (estää OOM ja levy-I/O pullonkaulat)
|
||||||
|
let ram_hit = RAM_CACHE.with(|cache| {
|
||||||
|
cache.borrow().get(key).cloned()
|
||||||
|
});
|
||||||
|
if let Some(bytes) = ram_hit {
|
||||||
|
console_log!("[Coder] {} löytyi nopeasta RAM-välimuistista!", key);
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tarkistetaan IndexedDB (jos selain on suljettu aikaisemmin)
|
||||||
|
if let Ok(Some(bytes)) = storage::load_from_idb(key).await {
|
||||||
|
console_log!("[Coder] {} löytyi IndexedDB-välimuistista ({} MB)", key, bytes.len() / 1024 / 1024);
|
||||||
|
let rc_bytes = Rc::new(bytes);
|
||||||
|
RAM_CACHE.with(|cache| cache.borrow_mut().insert(key.to_string(), rc_bytes.clone()));
|
||||||
|
return Ok(rc_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
console_log!("[Coder] Ladataan {}...", key);
|
||||||
|
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url))
|
||||||
|
.await.map_err(|e| format!("Fetch: {:?}", e))?;
|
||||||
|
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "Ei Response".to_string())?;
|
||||||
|
if !resp.ok() { return Err(format!("HTTP {}", resp.status())); }
|
||||||
|
|
||||||
|
let total_size: usize = resp.headers()
|
||||||
|
.get("content-length").ok().flatten()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let body = resp.body().ok_or("Ei bodyä")?;
|
||||||
|
let reader: web_sys::ReadableStreamDefaultReader = body.get_reader().dyn_into().map_err(|_| "Ei reader".to_string())?;
|
||||||
|
|
||||||
|
let mut data: Vec<u8> = Vec::with_capacity(total_size);
|
||||||
|
let mut last_pct: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let chunk = wasm_bindgen_futures::JsFuture::from(reader.read())
|
||||||
|
.await.map_err(|e| format!("Read: {:?}", e))?;
|
||||||
|
let done = js_sys::Reflect::get(&chunk, &"done".into()).ok().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
|
if done { break; }
|
||||||
|
let value = js_sys::Reflect::get(&chunk, &"value".into()).map_err(|_| "value puuttuu".to_string())?;
|
||||||
|
let array = js_sys::Uint8Array::new(&value);
|
||||||
|
let mut buf = vec![0u8; array.length() as usize];
|
||||||
|
array.copy_to(&mut buf);
|
||||||
|
data.extend_from_slice(&buf);
|
||||||
|
|
||||||
|
if total_size > 0 {
|
||||||
|
let pct = ((data.len() as f64 / total_size as f64) * 100.0) as u32;
|
||||||
|
if pct >= last_pct + 5 || pct == 100 {
|
||||||
|
last_pct = pct;
|
||||||
|
console_log!("[Coder] {} lataus: {}%", key, pct);
|
||||||
|
let msg = serde_json::json!({ "type": "download_progress", "file": key, "pct": pct, "loaded_mb": data.len()/1024/1024, "total_mb": total_size/1024/1024 });
|
||||||
|
let _ = ws.borrow().send_with_str(&msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console_log!("[Coder] Tallennetaan {} ({} MB) IndexedDB:hen...", key, data.len() / 1024 / 1024);
|
||||||
|
let _ = storage::save_to_idb(key, &data).await;
|
||||||
|
console_log!("[Coder] {} tallennettu!", key);
|
||||||
|
|
||||||
|
let rc_data = Rc::new(data);
|
||||||
|
RAM_CACHE.with(|cache| cache.borrow_mut().insert(key.to_string(), rc_data.clone()));
|
||||||
|
|
||||||
|
Ok(rc_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lataa tai palauttaa välimuistista valmiin mallin + tokenizerin
|
||||||
|
async fn get_or_build_model(use_3b: bool, ws: &Rc<RefCell<WebSocket>>) -> Result<(), String> {
|
||||||
|
// Tarkistetaan onko oikea malli jo muistissa
|
||||||
|
let cache_hit = MODEL_CACHE.with(|c| {
|
||||||
|
c.borrow().as_ref().map(|m| m.is_3b == use_3b).unwrap_or(false)
|
||||||
|
});
|
||||||
|
if cache_hit {
|
||||||
|
// Logitetaan kaikki välivaiheet valmiiksi, jotta pipeline-UI päivittyy
|
||||||
|
console_log!("[Coder] tokenizer löytyi (cache)");
|
||||||
|
console_log!("[Coder] model löytyi (cache)");
|
||||||
|
console_log!("[Coder] Malli ladattu (välimuistista)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = Device::Cpu;
|
||||||
|
let dtype = DType::F32;
|
||||||
|
|
||||||
|
// Tokenizer
|
||||||
|
let tok_url = if use_3b { TOKENIZER_3B_URL } else { TOKENIZER_05B_URL };
|
||||||
|
let tok_key = if use_3b { "coder3b-tokenizer.json" } else { "coder05b-tokenizer.json" };
|
||||||
|
let tok_bytes = ensure_cached(tok_key, tok_url, ws).await?;
|
||||||
|
let tokenizer = tokenizers::Tokenizer::from_bytes(&tok_bytes[..])
|
||||||
|
.map_err(|e| format!("Tokenizer: {}", e))?;
|
||||||
|
|
||||||
|
// Painot
|
||||||
|
let tensors = if use_3b {
|
||||||
|
let part1 = ensure_cached("coder3b-model-part1.safetensors", MODEL_3B_PART1_URL, ws).await?;
|
||||||
|
let part2 = ensure_cached("coder3b-model-part2.safetensors", MODEL_3B_PART2_URL, ws).await?;
|
||||||
|
console_log!("[Coder] Rakennetaan 3B-mallia...");
|
||||||
|
let mut all_tensors = candle_core::safetensors::load_buffer(&part1[..], &device)
|
||||||
|
.map_err(|e| format!("Part1: {}", e))?;
|
||||||
|
let tensors2 = candle_core::safetensors::load_buffer(&part2[..], &device)
|
||||||
|
.map_err(|e| format!("Part2: {}", e))?;
|
||||||
|
all_tensors.extend(tensors2);
|
||||||
|
all_tensors
|
||||||
|
} else {
|
||||||
|
let model_bytes = ensure_cached("coder05b-model.safetensors", MODEL_05B_URL, ws).await?;
|
||||||
|
console_log!("[Coder] Rakennetaan 0.5B-mallia...");
|
||||||
|
candle_core::safetensors::load_buffer(&model_bytes[..], &device)
|
||||||
|
.map_err(|e| format!("Safetensors: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let vb = VarBuilder::from_tensors(tensors, dtype, &device);
|
||||||
|
let config = if use_3b {
|
||||||
|
QwenConfig {
|
||||||
|
vocab_size: 151936, hidden_size: 2048, intermediate_size: 11008,
|
||||||
|
num_hidden_layers: 36, num_attention_heads: 16, num_key_value_heads: 2,
|
||||||
|
max_position_embeddings: 32768, sliding_window: 32768, max_window_layers: 36,
|
||||||
|
tie_word_embeddings: true, rope_theta: 1000000.0, rms_norm_eps: 1e-6,
|
||||||
|
use_sliding_window: false, hidden_act: candle_nn::Activation::Silu,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
QwenConfig {
|
||||||
|
vocab_size: 151936, hidden_size: 896, intermediate_size: 4864,
|
||||||
|
num_hidden_layers: 24, num_attention_heads: 14, num_key_value_heads: 2,
|
||||||
|
max_position_embeddings: 32768, sliding_window: 32768, max_window_layers: 21,
|
||||||
|
tie_word_embeddings: true, rope_theta: 1000000.0, rms_norm_eps: 1e-6,
|
||||||
|
use_sliding_window: false, hidden_act: candle_nn::Activation::Silu,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = QwenModel::new(&config, vb).map_err(|e| format!("Malli: {}", e))?;
|
||||||
|
console_log!("[Coder] Malli ladattu ja välimuistitettu");
|
||||||
|
|
||||||
|
MODEL_CACHE.with(|c| {
|
||||||
|
*c.borrow_mut() = Some(CachedModel { model, tokenizer, is_3b: use_3b });
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// use_3b: false = 0.5B (nopea), true = 3B (laadukas)
|
||||||
|
pub async fn run_coder_inference(prompt: String, ws: Rc<RefCell<WebSocket>>, use_3b: bool, task_id: Option<String>) {
|
||||||
|
let perf = web_sys::window().unwrap().performance().unwrap();
|
||||||
|
let size_label = if use_3b { "3B" } else { "0.5B" };
|
||||||
|
|
||||||
|
let start_load = perf.now();
|
||||||
|
|
||||||
|
if let Err(e) = get_or_build_model(use_3b, &ws).await {
|
||||||
|
console_log!("[Coder] Mallin lataus: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let load_time = perf.now() - start_load;
|
||||||
|
if load_time > 100.0 {
|
||||||
|
console_log!("[Coder] Malli ladattu ({:.0}ms). Generoidaan...", load_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsitaan JSON-prompti tai käytetään teksti sellaisenaan
|
||||||
|
let default_system = "You are a coding assistant. Respond with ONLY code. No explanations, no markdown, no comments unless asked.";
|
||||||
|
let (actual_prompt, system_msg, max_new_tokens) = if prompt.starts_with('{') {
|
||||||
|
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 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(256) as usize;
|
||||||
|
(p, s, m)
|
||||||
|
} else {
|
||||||
|
(prompt.clone(), default_system.to_string(), 256)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(prompt.clone(), default_system.to_string(), 256)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let (generated_text, tokens_generated, gen_time) = MODEL_CACHE.with(|cache| {
|
||||||
|
let mut cache = cache.borrow_mut();
|
||||||
|
let cached = cache.as_mut().expect("Malli pitää olla ladattu");
|
||||||
|
|
||||||
|
let encoding = cached.tokenizer.encode(formatted.as_str(), true)
|
||||||
|
.map_err(|e| format!("Encode: {}", e)).unwrap();
|
||||||
|
let input_ids: Vec<u32> = encoding.get_ids().to_vec();
|
||||||
|
let input_len = input_ids.len();
|
||||||
|
console_log!("[Coder] Syöte: {} tokenia", input_len);
|
||||||
|
|
||||||
|
let device = Device::Cpu;
|
||||||
|
let start_gen = perf.now();
|
||||||
|
let eos_token = 151645u32;
|
||||||
|
let temperature: f32 = 0.7;
|
||||||
|
let top_k: usize = 40;
|
||||||
|
let repetition_penalty: f32 = 1.15;
|
||||||
|
|
||||||
|
// Nollataan KV-cache edellisestä promptista
|
||||||
|
cached.model.clear_kv_cache();
|
||||||
|
|
||||||
|
let mut generated_text = String::new();
|
||||||
|
let mut tokens_generated: usize = 0;
|
||||||
|
let mut all_generated: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
// Prefill
|
||||||
|
let input = Tensor::new(input_ids.as_slice(), &device).and_then(|t| t.unsqueeze(0)).unwrap();
|
||||||
|
let logits = cached.model.forward(&input, 0).unwrap();
|
||||||
|
let logits = logits.squeeze(0).unwrap();
|
||||||
|
let logits = if logits.dims().len() == 2 {
|
||||||
|
logits.get(logits.dim(0).unwrap() - 1).unwrap()
|
||||||
|
} else { logits };
|
||||||
|
|
||||||
|
let mut next_token = crate::sampling::sample_top_k_with_penalty(&logits, top_k, temperature, &all_generated, repetition_penalty);
|
||||||
|
|
||||||
|
if next_token != eos_token {
|
||||||
|
if let Ok(text) = cached.tokenizer.decode(&[next_token], true) {
|
||||||
|
generated_text.push_str(&text);
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
all_generated.push(next_token);
|
||||||
|
tokens_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoregressive
|
||||||
|
let mut pos = input_len;
|
||||||
|
for _ in 1..max_new_tokens {
|
||||||
|
if next_token == eos_token { break; }
|
||||||
|
|
||||||
|
let input = Tensor::new(&[next_token], &device).and_then(|t| t.unsqueeze(0)).unwrap();
|
||||||
|
let logits = match cached.model.forward(&input, pos) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => { console_log!("[Coder] Forward pos {}: {}", pos, e); break; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let logits = logits.squeeze(0).unwrap();
|
||||||
|
let logits = if logits.dims().len() == 2 {
|
||||||
|
logits.get(logits.dim(0).unwrap() - 1).unwrap()
|
||||||
|
} else { logits };
|
||||||
|
next_token = crate::sampling::sample_top_k_with_penalty(&logits, top_k, temperature, &all_generated, repetition_penalty);
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
if next_token == eos_token { break; }
|
||||||
|
|
||||||
|
if let Ok(text) = cached.tokenizer.decode(&[next_token], true) {
|
||||||
|
generated_text.push_str(&text);
|
||||||
|
|
||||||
|
// Stop-sekvenssit: katkaistaan kun malli alkaa selittää
|
||||||
|
let lower = generated_text.to_lowercase();
|
||||||
|
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", "\n// Example", "\n// example", "\n# Example", "\n# example"] {
|
||||||
|
if let Some(pos) = generated_text.find(stop) {
|
||||||
|
generated_text.truncate(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
all_generated.push(next_token);
|
||||||
|
tokens_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gen_time = perf.now() - start_gen;
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
console_log!("[Coder] {} tokenia | {:.0}ms | {:.1} tok/s", tokens_generated, gen_time, tokens_per_sec);
|
||||||
|
|
||||||
|
let mut done = serde_json::json!({
|
||||||
|
"type": "llm_done",
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": format!("Qwen2.5-Coder-{}-Instruct", size_label),
|
||||||
|
"response": generated_text,
|
||||||
|
"tokens_generated": tokens_generated,
|
||||||
|
"duration_ms": (gen_time * 100.0).round() / 100.0,
|
||||||
|
"tokens_per_sec": (tokens_per_sec * 10.0).round() / 10.0,
|
||||||
|
"load_time_ms": (load_time * 100.0).round() / 100.0,
|
||||||
|
});
|
||||||
|
if let Some(tid) = task_id {
|
||||||
|
done.as_object_mut().unwrap().insert("task_id".to_string(), serde_json::json!(tid));
|
||||||
|
}
|
||||||
|
let _ = ws.borrow().send_with_str(&done.to_string());
|
||||||
|
}
|
||||||
113
network-poc/node/src/sampling.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use candle_core::Tensor;
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static RNG_STATE: Cell<u64> = Cell::new(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_rand() -> f32 {
|
||||||
|
RNG_STATE.with(|state| {
|
||||||
|
let mut s = state.get();
|
||||||
|
if s == 0 {
|
||||||
|
s = (js_sys::Date::now() * 1000.0) as u64 | 1;
|
||||||
|
}
|
||||||
|
s ^= s << 13;
|
||||||
|
s ^= s >> 7;
|
||||||
|
s ^= s << 17;
|
||||||
|
state.set(s);
|
||||||
|
(s % 10000) as f32 / 10000.0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-k sampling with temperature and repetition penalty.
|
||||||
|
/// `generated_tokens` sisältää aiemmin generoidut token-id:t toiston estämiseksi.
|
||||||
|
pub fn sample_top_k_with_penalty(logits: &Tensor, k: usize, temperature: f32, generated_tokens: &[u32], repetition_penalty: f32) -> u32 {
|
||||||
|
let mut logits_vec: Vec<f32> = logits.to_vec1::<f32>().unwrap_or_default();
|
||||||
|
if logits_vec.is_empty() { return 0; }
|
||||||
|
|
||||||
|
// Repetition penalty
|
||||||
|
if repetition_penalty != 1.0 {
|
||||||
|
for &token_id in generated_tokens {
|
||||||
|
if (token_id as usize) < logits_vec.len() {
|
||||||
|
let logit = &mut logits_vec[token_id as usize];
|
||||||
|
if *logit > 0.0 {
|
||||||
|
*logit /= repetition_penalty;
|
||||||
|
} else {
|
||||||
|
*logit *= repetition_penalty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temperature scaling
|
||||||
|
if temperature > 0.0 && temperature != 1.0 {
|
||||||
|
for logit in logits_vec.iter_mut() {
|
||||||
|
*logit /= temperature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-k
|
||||||
|
let mut indexed: Vec<(usize, f32)> = logits_vec.iter().enumerate().map(|(i, &v)| (i, v)).collect();
|
||||||
|
indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
indexed.truncate(k);
|
||||||
|
|
||||||
|
if k == 1 || temperature == 0.0 {
|
||||||
|
return indexed[0].0 as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Softmax top-k:lle
|
||||||
|
let max_logit = indexed[0].1;
|
||||||
|
let exps: Vec<f32> = indexed.iter().map(|x| (x.1 - max_logit).exp()).collect();
|
||||||
|
let sum: f32 = exps.iter().sum();
|
||||||
|
let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
|
||||||
|
|
||||||
|
let rand_val = next_rand();
|
||||||
|
|
||||||
|
let mut cumulative = 0.0;
|
||||||
|
for (i, p) in probs.iter().enumerate() {
|
||||||
|
cumulative += p;
|
||||||
|
if rand_val < cumulative {
|
||||||
|
return indexed[i].0 as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexed[0].0 as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alkuperäinen API yhteensopivuudeksi SmolLM/Qwen-moduulien kanssa
|
||||||
|
pub fn sample_top_k(logits: &Tensor, k: usize, eos_penalty: f32) -> u32 {
|
||||||
|
let mut logits_vec: Vec<f32> = logits.to_vec1::<f32>().unwrap_or_default();
|
||||||
|
if logits_vec.is_empty() { return 0; }
|
||||||
|
|
||||||
|
// EOS-penaltti
|
||||||
|
for &eos_id in &[2u32, 151645] {
|
||||||
|
if (eos_id as usize) < logits_vec.len() {
|
||||||
|
logits_vec[eos_id as usize] -= eos_penalty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut indexed: Vec<(usize, f32)> = logits_vec.iter().enumerate().map(|(i, &v)| (i, v)).collect();
|
||||||
|
indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
indexed.truncate(k);
|
||||||
|
|
||||||
|
if k == 1 {
|
||||||
|
return indexed[0].0 as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_logit = indexed[0].1;
|
||||||
|
let exps: Vec<f32> = indexed.iter().map(|x| (x.1 - max_logit).exp()).collect();
|
||||||
|
let sum: f32 = exps.iter().sum();
|
||||||
|
let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
|
||||||
|
|
||||||
|
let rand_val = next_rand();
|
||||||
|
|
||||||
|
let mut cumulative = 0.0;
|
||||||
|
for (i, p) in probs.iter().enumerate() {
|
||||||
|
cumulative += p;
|
||||||
|
if rand_val < cumulative {
|
||||||
|
return indexed[i].0 as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexed[0].0 as u32
|
||||||
|
}
|
||||||
236
network-poc/node/src/smollm.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
use candle_core::{Device, Tensor, DType};
|
||||||
|
use candle_nn::VarBuilder;
|
||||||
|
use candle_transformers::models::llama::{Llama, LlamaConfig, LlamaEosToks, Cache};
|
||||||
|
// LogitsProcessor poistettu — käytetään greedy samplingia (argmax) Wasm-yhteensopivuuden vuoksi
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use web_sys::WebSocket;
|
||||||
|
|
||||||
|
use crate::storage;
|
||||||
|
|
||||||
|
macro_rules! console_log {
|
||||||
|
($($t:tt)*) => (web_sys::console::log_1(&format_args!($($t)*).to_string().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_URL: &str = "https://huggingface.co/HuggingFaceTB/SmolLM-135M-Instruct/resolve/main/model.safetensors";
|
||||||
|
const TOKENIZER_URL: &str = "https://huggingface.co/HuggingFaceTB/SmolLM-135M-Instruct/resolve/main/tokenizer.json";
|
||||||
|
|
||||||
|
/// Lataa tiedosto HuggingFacesta streaming-latauksella (progress-ilmoitukset) ja tallentaa IndexedDB:hen
|
||||||
|
async fn ensure_cached(key: &str, url: &str, ws: &Rc<RefCell<WebSocket>>) -> Result<Vec<u8>, String> {
|
||||||
|
if let Ok(Some(bytes)) = storage::load_from_idb(key).await {
|
||||||
|
console_log!("[SmolLM] {} löytyi välimuistista ({} MB)", key, bytes.len() / 1024 / 1024);
|
||||||
|
send_progress(ws, key, 100, bytes.len(), bytes.len());
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
console_log!("[SmolLM] Ladataan {}...", key);
|
||||||
|
send_progress(ws, key, 0, 0, 0);
|
||||||
|
|
||||||
|
// Fetch API:lla saadaan Content-Length ja streaming-luku
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url))
|
||||||
|
.await.map_err(|e| format!("Fetch epäonnistui: {:?}", e))?;
|
||||||
|
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "Ei Response-objekti".to_string())?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kokonaiskoko Content-Length-headerista
|
||||||
|
let total_size: usize = resp.headers()
|
||||||
|
.get("content-length").ok().flatten()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let body = resp.body().ok_or("Ei bodyä")?;
|
||||||
|
let reader = body.get_reader();
|
||||||
|
let reader: web_sys::ReadableStreamDefaultReader = reader.dyn_into().map_err(|_| "Ei ReadableStreamDefaultReader".to_string())?;
|
||||||
|
|
||||||
|
let mut data: Vec<u8> = Vec::with_capacity(total_size);
|
||||||
|
let mut last_pct: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let chunk = wasm_bindgen_futures::JsFuture::from(reader.read())
|
||||||
|
.await.map_err(|e| format!("Luku epäonnistui: {:?}", e))?;
|
||||||
|
|
||||||
|
let done = js_sys::Reflect::get(&chunk, &"done".into())
|
||||||
|
.map_err(|_| "done-kenttä puuttuu".to_string())?
|
||||||
|
.as_bool().unwrap_or(true);
|
||||||
|
|
||||||
|
if done { break; }
|
||||||
|
|
||||||
|
let value = js_sys::Reflect::get(&chunk, &"value".into())
|
||||||
|
.map_err(|_| "value-kenttä puuttuu".to_string())?;
|
||||||
|
let array = js_sys::Uint8Array::new(&value);
|
||||||
|
let mut buf = vec![0u8; array.length() as usize];
|
||||||
|
array.copy_to(&mut buf);
|
||||||
|
data.extend_from_slice(&buf);
|
||||||
|
|
||||||
|
// Progress-päivitys (joka 5%)
|
||||||
|
if total_size > 0 {
|
||||||
|
let pct = ((data.len() as f64 / total_size as f64) * 100.0) as u32;
|
||||||
|
if pct >= last_pct + 5 || pct == 100 {
|
||||||
|
last_pct = pct;
|
||||||
|
console_log!("[SmolLM] {} lataus: {}% ({}/{} MB)", key, pct, data.len() / 1024 / 1024, total_size / 1024 / 1024);
|
||||||
|
send_progress(ws, key, pct, data.len(), total_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console_log!("[SmolLM] Tallennetaan {} ({} MB) IndexedDB:hen...", key, data.len() / 1024 / 1024);
|
||||||
|
let _ = storage::save_to_idb(key, &data).await;
|
||||||
|
console_log!("[SmolLM] {} tallennettu!", key);
|
||||||
|
send_progress(ws, key, 100, data.len(), data.len());
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_progress(ws: &Rc<RefCell<WebSocket>>, file: &str, pct: u32, loaded: usize, total: usize) {
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "download_progress",
|
||||||
|
"file": file,
|
||||||
|
"pct": pct,
|
||||||
|
"loaded_mb": loaded / 1024 / 1024,
|
||||||
|
"total_mb": total / 1024 / 1024,
|
||||||
|
});
|
||||||
|
let _ = ws.borrow().send_with_str(&msg.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lataa malli ja tokenizer, suorita inferenssi ja streamaa tokenit hubille
|
||||||
|
pub async fn run_smollm_inference(prompt: String, ws: Rc<RefCell<WebSocket>>) {
|
||||||
|
let perf = web_sys::window().unwrap().performance().unwrap();
|
||||||
|
|
||||||
|
// 1. Lataa tokenizer
|
||||||
|
let tok_bytes = match ensure_cached("smollm-tokenizer.json", TOKENIZER_URL, &ws).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => { console_log!("[SmolLM] Tokenizer-virhe: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let tokenizer = match tokenizers::Tokenizer::from_bytes(&tok_bytes) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => { console_log!("[SmolLM] Tokenizer-parsinta epäonnistui: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Lataa mallin painot
|
||||||
|
let model_bytes = match ensure_cached("smollm-model.safetensors", MODEL_URL, &ws).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => { console_log!("[SmolLM] Malli-virhe: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
model_bytes: Vec<u8>,
|
||||||
|
tokenizer: tokenizers::Tokenizer,
|
||||||
|
ws: Rc<RefCell<WebSocket>>,
|
||||||
|
perf: web_sys::Performance, // Korjattu Wasm-performanssi välitettäväksi
|
||||||
|
) {
|
||||||
|
let start_load = perf.now();
|
||||||
|
|
||||||
|
let device = Default::default();
|
||||||
|
let config = crate::burn_smollm::config::SmolLMConfig::default();
|
||||||
|
|
||||||
|
console_log!("[SmolLM] Injektoidaan Safetensors -> Burn Params...");
|
||||||
|
let model = match crate::burn_smollm::loader::load_safetensors_to_model::<B>(&model_bytes, &config, &device) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => { console_log!("[SmolLM] Lataus epäonnistui: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let load_time = perf.now() - start_load;
|
||||||
|
console_log!("[SmolLM] Burn-malli ladattu ({:.0}ms). Generoidaan...", load_time);
|
||||||
|
|
||||||
|
let formatted_prompt = format!("<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", prompt);
|
||||||
|
let encoding = match tokenizer.encode(formatted_prompt.as_str(), true) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => { console_log!("[SmolLM] Tokenisointivirhe: {}", e); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut input_ids: Vec<u32> = encoding.get_ids().to_vec();
|
||||||
|
let input_len = input_ids.len();
|
||||||
|
console_log!("[SmolLM] Syöte: {} tokenia", input_len);
|
||||||
|
|
||||||
|
let start_gen = perf.now();
|
||||||
|
let max_new_tokens = 32;
|
||||||
|
let mut generated_text = String::new();
|
||||||
|
let mut tokens_generated: usize = 0;
|
||||||
|
|
||||||
|
// KV-välimuistin taulukko kerroksittain
|
||||||
|
let mut caches: Vec<Option<crate::burn_smollm::attention::KVCache<B>>> = vec![None; config.num_hidden_layers];
|
||||||
|
let mut current_offset = 0;
|
||||||
|
|
||||||
|
// Prefill: yksitellen, vältetään future token leakage koska ei causal maskia
|
||||||
|
let input_ids_i32: Vec<i32> = input_ids.iter().map(|&x| x as i32).collect();
|
||||||
|
let mut last_logits = None;
|
||||||
|
|
||||||
|
for &id in &input_ids_i32 {
|
||||||
|
let input_tensor = burn::tensor::Tensor::<B, 1, burn::tensor::Int>::from_data(
|
||||||
|
burn::tensor::TensorData::from([id]),
|
||||||
|
&device
|
||||||
|
).unsqueeze::<2>(); // [1, 1]
|
||||||
|
|
||||||
|
last_logits = Some(model.forward(input_tensor, current_offset, &mut caches));
|
||||||
|
current_offset += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut logits = last_logits.unwrap();
|
||||||
|
|
||||||
|
// Argmax sämpläys
|
||||||
|
let next_token_tensor = logits.clone().argmax(2);
|
||||||
|
let mut next_token: u32 = next_token_tensor.into_scalar().to_string().parse().unwrap_or(2); // Yksinkertainen cast koska int scalar
|
||||||
|
|
||||||
|
if next_token != 2 {
|
||||||
|
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": "SmolLM-135M (WebGPU)" });
|
||||||
|
let _ = ws.borrow().send_with_str(&chunk.to_string());
|
||||||
|
}
|
||||||
|
tokens_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoregressiivinen luuppi
|
||||||
|
for _ in 1..max_new_tokens {
|
||||||
|
if next_token == 2 { break; }
|
||||||
|
|
||||||
|
let mut input_tensor = burn::tensor::Tensor::<B, 1, burn::tensor::Int>::from_data(
|
||||||
|
burn::tensor::TensorData::from([next_token as i32]),
|
||||||
|
&device
|
||||||
|
).unsqueeze::<2>();
|
||||||
|
|
||||||
|
logits = model.forward(input_tensor, current_offset, &mut caches);
|
||||||
|
current_offset += 1;
|
||||||
|
|
||||||
|
let next_token_tensor = logits.argmax(2);
|
||||||
|
next_token = next_token_tensor.into_scalar().to_string().parse().unwrap_or(2);
|
||||||
|
|
||||||
|
if next_token == 2 { break; }
|
||||||
|
|
||||||
|
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": "SmolLM-135M (WebGPU)" });
|
||||||
|
let _ = ws.borrow().send_with_str(&chunk.to_string());
|
||||||
|
}
|
||||||
|
tokens_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gen_time = perf.now() - start_gen;
|
||||||
|
let tokens_per_sec = if gen_time > 0.0 { (tokens_generated as f64 / gen_time) * 1000.0 } else { 0.0 };
|
||||||
|
|
||||||
|
let done = serde_json::json!({
|
||||||
|
"type": "llm_done",
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": "SmolLM-135M-Instruct (WebGPU)",
|
||||||
|
"response": generated_text,
|
||||||
|
"tokens_generated": tokens_generated,
|
||||||
|
"duration_ms": (gen_time * 100.0).round() / 100.0,
|
||||||
|
"tokens_per_sec": (tokens_per_sec * 10.0).round() / 10.0,
|
||||||
|
"load_time_ms": (load_time * 100.0).round() / 100.0,
|
||||||
|
});
|
||||||
|
let _ = ws.borrow().send_with_str(&done.to_string());
|
||||||
|
}
|
||||||
BIN
network-poc/nodes.db
Normal file
34
network-poc/static/avatars/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Kipinä Agentic Playground - Animaatioiden käyttöönotto
|
||||||
|
|
||||||
|
Koska Kipinä-verkon agenttien avatarit tällä erää ovat staattisia PNG-kuvatiedostoja, käyttöliittymä hyödyntää CSS-pohjaista pomppimisilmiötä (sekä pulppuavaa 💬 puhekuplaa) "puhumisen" merkkinä. Olemme kuitenkin koodanneet taustalle piilotetun tuen aivioiduille videoloopeille myöhempää käyttöä varten!
|
||||||
|
|
||||||
|
Näin saat UI:n tukemaan oikeasti animoituja kasvoja/videoita.
|
||||||
|
|
||||||
|
## 1. Luo Animoidut GIF-tiedostot
|
||||||
|
Valitse mikä tahansa ulkoinen AI-työkalu (kuten HeyGen, Pika v1.0, tai Midjourney+Runway yhdistelmä) ja muunna avatar-kuvat (esim. `kettu_notext.png`) 3-5 sekunnin kestäviksi GIF-loopeiksi. Hahmon leuka tulisi pyöriä tai naama vääntyillä puhuessaan.
|
||||||
|
|
||||||
|
## 2. Nimeä Tiedostot Oikein ja Lisää Ne Kansioon
|
||||||
|
Siirrä uudet GIF-animaatiot samaan kansioon alkuperäisten kuvien kanssa. Muuta niiden nimi siten, että se päättyy tunnisteeseen `_puhuva.gif`.
|
||||||
|
|
||||||
|
Esimerkkejä:
|
||||||
|
- Koodari `kipina_notext.png` → `kipina_notext_puhuva.gif`
|
||||||
|
- Manageri `karhunpentu.png` → `karhunpentu_puhuva.gif`
|
||||||
|
- Asiakas `kettu_notext.png` → `kettu_notext_puhuva.gif`
|
||||||
|
|
||||||
|
## 3. Aktivoi Koodi
|
||||||
|
Käännä Kipinä Playground -ohjaimen JavaScript-koodista piilotettu ominaisuus päälle.
|
||||||
|
|
||||||
|
Etsi tiedostosta `../index.html` (noin riviltä 1084, `updatePromptEditor`-funktiosta):
|
||||||
|
```javascript
|
||||||
|
// Piilotettu ominaisuus: Puhuvien videoiden / gif-animaatioiden kytkentä
|
||||||
|
window.USE_ANIMATED_GIFS = false;
|
||||||
|
```
|
||||||
|
Muuta tuo `false` arvoon `true`:
|
||||||
|
```javascript
|
||||||
|
window.USE_ANIMATED_GIFS = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mitä logiikka tekee?**
|
||||||
|
Aina kun valitset agentin kaaviosta, koodi korvaa aktiivisen kuvakkeen lopussa olevan `.png` -päätteen sanalla `_puhuva.gif` – lennosta! Jos poistut agentin valinnasta tai valitset jonkun toisen, koodi vaihtaa kuvan välittömästi takaisin staattiseen `.png`-versioon ja sulkee ilmentymän suun.
|
||||||
|
|
||||||
|
Näin saat kaikkien asiantuntijoiden face-track looppeja hallittua yhdellä kädenkäänteellä.
|
||||||
BIN
network-poc/static/avatars/aikuinen_susi.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
network-poc/static/avatars/karhunpentu.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
network-poc/static/avatars/kettu_notext.png
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
network-poc/static/avatars/kipina_notext.png
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
network-poc/static/avatars/laiskiainen.png
Normal file
|
After Width: | Height: | Size: 596 KiB |
BIN
network-poc/static/avatars/laiskiainen_notext.png
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
network-poc/static/avatars/old/forge_hero.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
network-poc/static/avatars/old/gecko_hero.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
network-poc/static/avatars/old/kipina.png
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
network-poc/static/avatars/old/serpent_hero.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
network-poc/static/avatars/pesukarhu.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
network-poc/static/avatars/pesukarhu_notext.png
Normal file
|
After Width: | Height: | Size: 563 KiB |
BIN
network-poc/static/avatars/susi_notext.png
Normal file
|
After Width: | Height: | Size: 513 KiB |
@@ -1 +0,0 @@
|
|||||||
{"rustc_fingerprint":15841952146704291179,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.1 (e408947bf 2026-03-25)\nbinary: rustc\ncommit-hash: e408947bfd200af42db322daf0fadfe7e26d3bd1\ncommit-date: 2026-03-25\nhost: x86_64-unknown-linux-gnu\nrelease: 1.94.1\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/jaakko/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Signature: 8a477f597d28d172789f06886806bc55
|
|
||||||
# This file is a cache directory tag created by cargo.
|
|
||||||
# For information about cache directory tags see https://bford.info/cachedir/
|
|
||||||