65 Commits

Author SHA1 Message Date
20cea8f268 Model benchmark: testaa kaikki Ollama-mallit järjestelmällisesti
Ajaa täyden pipeline-kierroksen per malli × skenaario:
1. Client-prompti → vaatimukset
2. Manager/SPEC_SYSTEM → JSON-speksi
3. Template-generointi → koodi
4. Validointi + LLM-korjaussilmukka
5. uv sync + pytest

Tuottaa vertailutaulukon: speksin laatu, testien tulos, nopeus.
Tukee suoraa Ollamaa (--ollama) ja hub-reittiä (--hub).
2026-04-13 22:08:47 +03:00
38a18c555b Debug: reititys logittaa kaikki solmut ja niiden tilat 2026-04-13 21:53:40 +03:00
8138e41aa1 native-noden tuunausta 2026-04-13 21:29:05 +03:00
6ee5bdf960 Native node: lämmittelykutsu lataa mallin VRAM:iin heti käynnistyksessä 2026-04-13 21:23:56 +03:00
cf3bf54bf8 kipina-node: automaattinen versiopäivitys build-hashilla
Poistettu interaktiivinen "haluatko korvata?" -kysely. Tilalle:
- Bootstrap hakee .build-hash palvelimelta joka käynnistyksellä
- Vertaa paikalliseen kipina-node-bin.hash
- Lataa uuden automaattisesti jos hash eroaa
- Näyttää version käynnistyksen yhteydessä

Ei enää tilannetta jossa vanha binääri jää vahingossa ajoon.
2026-04-13 21:21:48 +03:00
56f21a96c9 TUI: VRAM-tila värikoodattu (vihreä=100% GPU, keltainen=osittainen, punainen=CPU) 2026-04-13 21:12:50 +03:00
763b93396c Reititys: busy-solmut suodatetaan pois — työ jakautuu solmuille
Aiemmin busy-lukko luettiin mutta sitä ei käytetty suodatukseen,
joten sama solmu valittiin aina uudelleen vaikka se oli varattu.
Nyt matching-lista suodattaa pois busy-solmut, joten toinen
vapaa solmu saa tehtävän. Heavy-fallback kevyempään solmuun
jos kaikki isot mallit ovat varattuja.
2026-04-13 21:09:24 +03:00
e09962940a Native node: VRAM-tila TUI:ssa (ollama ps)
- fetch_ps(): hakee /api/ps ja palauttaa ModelVramStatus
- ModelVramStatus: size vs size_vram → 100% GPU / osittainen / CPU
- TUI: uusi "VRAM: ✓ qwen3:32b (20.1 GB) — 100% GPU" -rivi
- Taustapäivitys 30s välein
- Tuore linux-x86_64 binääri
2026-04-13 21:06:27 +03:00
5e44b63b0c Native node: tuore linux-x86_64 -binääri (reconnect, timestamp, node_id) 2026-04-13 16:54:28 +03:00
0f3881aa02 Fix: async RwLock read ennen Mutex-scopea (Send-yhteensopivuus) 2026-04-13 16:34:51 +03:00
fa85dcc5b3 Älykäs reititys: capability=heavy priorisoi isoimman mallin solmun
Hub:
- Parsii node_models:sta suurimman mallin parametrimäärän (B)
  per solmu (esim. qwen3:32b → 32, qwen2.5-coder:7b → 7)
- Tallentaa node_max_param_b: HashMap<u64, u32>
- ChatCompletionRequest: uusi capability-kenttä ("heavy"/"light")
- Reitityslogiikka: capability=heavy → valitsee solmun jolla on
  suurin malli; oletus → natiivi ensin kuten ennenkin

Frontend (pipeline):
- JSON-speksin generointi: capability=heavy
- QA-korjaussilmukan koodikorjaus: capability=heavy
- Observer/README-arviointi: capability=heavy
- Vaatimukset (Client): oletus (kevyt, kelpaa pieni malli)

Tämä mahdollistaa sen, että A40-koneella pyörivä Qwen3:32B
saa raskaat tehtävät ja selaimen 0.5B-malli hoitaa kevyet.
2026-04-13 16:30:47 +03:00
58d93613f0 Hero-kuvat: oikeat kipina.tech-kuvat (forge, serpent, gecko) 2026-04-13 14:33:11 +03:00
66b4435362 Teemavalitsin: painike kiertää gecko/forge/serpent, oletus forge
- Teemapainike (emoji) oikeaan yläkulmaan kuten kipina.tech:ssä
- Oletus forge (syaani), tallennetaan localStorage:iin
- Hero-kuva vaihtuu teeman mukaan fade-efektillä
- Kolme hero-kuvaa: gecko_hero, forge_hero (hämähäkki), serpent_hero
2026-04-13 14:29:14 +03:00
3a00de9b8e Kolme kipina.tech-teemaa: gecko, forge, serpent — satunnaisvalinta
Tuodaan kipina.techin kolme visuaalista teemaa kipina.studioon:
- gecko: lämmin kulta/oranssi (#ff7b00)
- forge: kyber-sininen/syaani (#00e5ff)
- serpent: neon-turkoosi (#00ffff)

Teema arvotaan satunnaisesti joka sivulatauksella. Kaikki aiemmin
hardcoodatut #ff6b00-aksenttivärit korvattu CSS-muuttujilla
(--hero-accent, --hero-glow) jotka mukautuvat teemaan.
2026-04-13 14:22:33 +03:00
670141c8c3 QA-korjaussilmukka: validointi delegoi ongelmat Coder-agentille
Aiemmin mekaaninen validateProjectCode() vain listasi ongelmat terminaaliin.
Nyt pipeline toimii näin:
1. QA-agentti ajaa mekaanisen validoinnin
2. Jos ongelmia → ryhmittelee ne tiedostoittain
3. Delegoi jokaisen tiedoston korjauksen oikealle agentille (Coder/Data/QA)
4. Agentti (LLM) palauttaa korjatun tiedoston
5. Validointi ajetaan uudelleen — max 2 korjauskierrosta
6. Lopullinen tulos näytetään vihreänä/punaisena
7. Tarkkailija arvioi lopullisen version

Kaikki korjausvaiheet tallentuvat promptLog:iin → näkyvät oppimispolussa.
2026-04-13 14:09:10 +03:00
59daebbd38 Template pipeline: docker-compose.yml ja .dockerignore mukaan generointiin
Jokainen generoitu projekti sisältää nyt:
- Dockerfile (oli jo)
- docker-compose.yml (uusi: build + portti 8000 + named volume)
- .dockerignore (uusi: .venv, __pycache__, *.db, .git)

Testattu: docker compose build + kontin käynnistys + API-kutsu OK.
2026-04-13 13:27:50 +03:00
42b71dbf77 Templatejen laatu: declarative_base, ConfigDict, ForeignKey
- models.py: sqlalchemy.ext.declarative → sqlalchemy.orm (poistaa
  MovedIn20Warning-varoituksen)
- schemas.py: class Config → model_config = ConfigDict() (poistaa
  PydanticDeprecatedSince20-varoituksen)
- models.py: _id-kentät saavat ForeignKey("taulu.id") kun speksissä
  on relationship-merkintä

Testattu: 10 erilaista projektia, 78/78 testiä läpi, 0 varoitusta.
2026-04-13 13:18:11 +03:00
b88a741f85 Template pipeline: JS→Python -arvomuunnokset korjattu
Ongelma: generoiduissa Python-tiedostoissa JS-booleanit (false/true)
päätyvät sellaisenaan Python-koodiin, jossa ne eivät ole valideja.
Lisäksi datetime-importit puuttuivat kun LLM antoi extra_imports-kentässä
pelkän "datetime"-merkkijonon eikä kokonaista import-lausetta.

Korjaukset:
- pyLiteral(): muuntaa JS-arvot Python-literaaleiksi (false→False jne.)
- pyJsonLiteral(): testidatan serialisointi Python-dict-muodossa
- tmplSchemas: datetime-importit tunnistetaan automaattisesti kentistä
- tmplModels + tmplSchemas: oletusarvot pyLiteral()-funktion kautta
- tmplTests: JSON.stringify korvattu pyJsonLiteral():lla
- Validaattori: tunnistaa nyt datetime-import-puutteet ja JS-booleanit

Testattu: molemmat aiemmin rikkinäiset speksit generoivat nyt toimivan
koodin — 6/6 pytest-testiä läpi molemmilla.
2026-04-13 12:44:08 +03:00
Jaakko Vanhala
68c7195d54 TEMPLATING.md: periaatteet rakennuspalapohjaiselle koodigeneroinnille 2026-04-13 06:59:12 +03:00
Jaakko Vanhala
3d20238eef Uudelleenreititystä ja templatingia 2026-04-13 06:54:56 +03:00
Jaakko Vanhala
8b8ba01af3 Toipuminen yhteyskatkoksesta: hub ilmoittaa API:lle, node reconnectaa
- Hub: kun node katoaa kesken tehtävän, palauttaa virheen API-kutsulle
- Hub: node_active_task seuraa mikä tehtävä on kesken
- Hub: timeout 600s → 120s
- Node: reconnect nollaa busy-tilan ja näyttää sen TUI:ssa
2026-04-13 06:50:45 +03:00
Jaakko Vanhala
a3b95a56e8 Native node: timestamp lokiin, node_id otsikkoon, yhdistetty-tila 2026-04-13 06:32:11 +03:00
Jaakko Vanhala
5b20ebe800 Opas: terminologia korjattu — relaatio on taulu, relationship on yhteys 2026-04-12 20:32:52 +03:00
Jaakko Vanhala
ffe9bd6902 Opas päivitetty: relaatiotuki, architect-agentin rooli, vertailuluvut 2026-04-12 20:27:41 +03:00
Jaakko Vanhala
d27068b11a UI-korjaus korjattu (GTFO gemini) 2026-04-12 20:18:39 +03:00
Jaakko Vanhala
8468724a4c Architect-prompti parannettu, relaatiotuki templateihin, englanti-sääntö
- SPEC_SYSTEM: chain-of-thought, domain-esimerkit, anti-patternit, relaatiosäännöt
- Speksi-puhdistus: korjaa sa_type | None -virheet automaattisesti
- Etusivun teksti päivitetty
- Koodissa käytetään aina englantia (entity/field names)
2026-04-12 20:15:22 +03:00
Jaakko Vanhala
6ef71b7e5c Templates for different tasks 2026-04-12 20:02:25 +03:00
Jaakko Vanhala
b2ee8b9031 Pipelinen parannuksia building blockeilla 2026-04-12 18:48:14 +03:00
Jaakko Vanhala
c1a5f8aff5 ZIP-tiedostonimi lyhennetty max 3 sanaan 2026-04-12 16:07:46 +03:00
Jaakko Vanhala
8ee997cb56 Projektin ZIP-lataus projektikorttiin
Lataa .zip -nappi renderöidään projektikortin headeriin.
ZIP rakennetaan selaimessa ilman ulkoisia kirjastoja (CRC-32 + ZIP-rakenne inline).
Kansiorakenne säilyy: prompts/*.md -tiedostot menevät alihakemistoon.
2026-04-12 15:59:14 +03:00
Jaakko Vanhala
cd67562a67 QA katselmoi, DevOps keskittyy deploymenttiin
- Review-luuppi siirretty DevOps→QA: QA katselmoi koodin ja
  lähettää korjausvaatimukset Coderille (max 3 kierrosta)
- QA:n prompt laajennettu: review-checklist + testien kirjoitus
- DevOps:n prompt uusittu: Dockerfile + deployment -fokus
- Pipeline: Client→Manager→Coder→QA review↔Coder fix→QA testit→DevOps Dockerfile→Observer
- AGENTS_VERSION 4→5
2026-04-12 15:55:45 +03:00
Jaakko Vanhala
1f85c03624 Pipeline-rajoitteet kevennetty ja näkyville Asetukset-sivulle
- maxTokens: client/manager/devops/observer 512→1024
- Client: 200→400 sanaa, 3-5→3-8 ominaisuutta, MVP-rajoitus poistettu
- Manager: 4-5→8 tiedostoa, vapaa tila 6→8
- Terminaali: 100→300 riviä, CrewAI prompt truncation 20→50 riviä
- Uusi pipelineConfig-objekti (localStorage-persistenssi)
- Asetukset-sivulle Pipeline-rajoitteet -osio sliderien kanssa
- AGENTS_VERSION 3→4
2026-04-12 15:47:46 +03:00
Jaakko Vanhala
74a2045def Landing page + oppimispolku + esimerkkiprojektit
1) Landing: gecko hero, projektin syöttökenttä, "Käynnistä"-nappi
2) Oppimispolku-välilehti: promptLog step-by-step (system prompt, syöte, tulos)
3) Kolme esimerkkiprojektia: Käyttäjähallinta-API, UWB-data-analyysi, Todo-sovellus
4) Landing → App -siirtymä käynnistää pipelinen suoraan
2026-04-12 15:24:44 +03:00
Jaakko Vanhala
9b2b7767b5 Depoa paranneltu 2026-04-12 14:28:58 +03:00
Jaakko Vanhala
1718805978 CrewAI-yhteensopiva projektioutput: agents.yaml, tasks.yaml, crew.py, prompts/
Pipeline kerää promptLog-listan jokaisesta agenttikutsusta (system prompt +
syöte + tulos) ja generoi lopuksi CrewAI-rakenteen files-objektiin.
Korjattu myös template.order.length-kaatuminen vapaassa tilassa.
2026-04-12 13:41:04 +03:00
Jaakko Vanhala
7fcc97f525 docker-compose.prod: poistettu dist-volume mount joka yliajoi Docker-imagen frontendin 2026-04-12 12:00:21 +03:00
Jaakko Vanhala
7ce990b42a Dockerfile.prod: frontend COPY-polut korjattu (src/ → ./src/) 2026-04-12 11:56:47 +03:00
Jaakko Vanhala
dc71829430 Riippuvuuksien siivous: burn, smollm, phi3, uuid, log, console poistettu 2026-04-12 11:53:36 +03:00
Jaakko Vanhala
5d4a553520 riippuvuuksia karsittu 2026-04-12 11:49:08 +03:00
Jaakko Vanhala
5e82c798b1 vcachet kusee 2026-04-12 11:46:23 +03:00
Jaakko Vanhala
5f147b774f deployment kokonaan uusiksi 2026-04-12 11:41:09 +03:00
Jaakko Vanhala
4983217ee0 korjailtu depon cacheja 2026-04-12 11:20:06 +03:00
Jaakko Vanhala
27c33e41c3 v0.3.2: Asiakas-agentti, dynaaminen pipeline, /api/chat, kpn stop 2026-04-12 11:09:24 +03:00
Jaakko Vanhala
2b33980be4 buildia viilattu 2026-04-12 11:05:35 +03:00
Jaakko Vanhala
8995bcef30 ui updates 2026-04-12 10:40:56 +03:00
Jaakko Vanhala
2f140c8a15 uusi projekti 2026-04-12 10:28:57 +03:00
Jaakko Vanhala
094b183c17 toimii suht ok 2026-04-12 08:02:17 +03:00
Jaakko Vanhala
a91b9539b3 Promptin generointiin muutoksia 2026-04-12 07:43:59 +03:00
Jaakko Vanhala
6e2f85daa8 Lisätty *.log gitignoreen, poistettu native-node.log seurannasta 2026-04-12 07:41:34 +03:00
Jaakko Vanhala
466e61d730 Cache-busting: kipina-node lataus- ja asennusskripti ohittaa välimuistin
StatusBar ja kipina-node-skripti käyttävät ?v=timestamp-parametria
välimuistin ohittamiseen. Binäärin uudelleenlataus oletuksena Y.
deploy-binaries.sh kopioi myös kipina-node-skriptin palvelimelle.
2026-04-12 07:40:33 +03:00
Jaakko Vanhala
5f00582053 UI:n system prompt ja sampling-parametrit välittyvät inferenssiin asti
Frontend lähettää agentin asetukset (system_prompt, temperature, top_k,
max_tokens, repeat_penalty, stop) API:lle. Hub välittää ne solmulle.
Native-node ja Wasm-coder käyttävät välitettyjä arvoja hardkoodattujen
sijaan.
2026-04-12 07:39:41 +03:00
Jaakko Vanhala
e272b0d124 TUI build korjattu 2026-04-12 06:43:12 +03:00
Jaakko Vanhala
d3affb3a09 TUI again 2026-04-12 06:33:10 +03:00
Jaakko Vanhala
1377e72f78 TUI inc 2026-04-12 06:26:34 +03:00
Jaakko Vanhala
403f35efdc TUI inc 2026-04-12 06:22:52 +03:00
Jaakko Vanhala
ce0ccbddd3 Jotain jännää 2026-04-11 19:17:48 +03:00
Jaakko Vanhala
80806498e0 Remote start stop control 2026-04-11 19:14:20 +03:00
Jaakko Vanhala
660e80c2bc natiivinodehommajuttuja 2026-04-11 18:14:08 +03:00
Jaakko Vanhala
591cfcb04b Päivitetyt kipina-node-binäärit: macOS, Linux x86/ARM, Windows
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:04:53 +03:00
Jaakko Vanhala
3cda57f0bc Hub: solmujen mallilistaus muistiin + /api/tags palauttaa verkon mallit
Natiivisolmun auth-viestistä tallennetaan mallilistaus node_models-mappiin.
/api/tags priorisoi verkon solmujen malleja lokaalin Ollaman edelle.
api_hardware käyttää tietokannan litteää rakennetta.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:04:41 +03:00
Jaakko Vanhala
23e7b92d03 kipina-node: auth-viesti välittää mallinimen ja Ollama-mallilistauksen hubille
build_auth_message käyttää nyt oikeaa mallinimeä hardkoodatun sijaan.
Lisäksi natiivisolmu hakee Ollaman mallilistauksen ja lähettää sen
auth-viestissä hubille.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:04:23 +03:00
Jaakko Vanhala
9f58febe21 Deploy-putki: Windows-build + automaattinen binäärikäännös
build-binaries.sh: lisätty Windows x86_64 (mingw-w64) neljänneksi
kohteeksi. deploy.sh: binäärit käännetään automaattisesti ennen
Docker-buildia, jolloin ne päätyvät Astron kautta kipina.studioon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:03:53 +03:00
Jaakko Vanhala
b1de0d37f7 lisätty admin laitteistonäkymä 2026-04-11 17:42:17 +03:00
Jaakko Vanhala
4ff626ab88 broadcastit pois 2026-04-11 17:37:16 +03:00
Jaakko Vanhala
a45616046d Hub: broadcast-viestittely korvattu kohdennetulla reitityksellä
API-vastaukset käyttävät nyt oneshot-kanavaa broadcast-suodatuksen
sijaan, ja user_text lähetetään vain lähettäjäsolmulle. Stats-broadcast
säilyy UI:lle ja adminille.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:36:24 +03:00
59 changed files with 4035 additions and 1609 deletions

8
.gitignore vendored
View File

@@ -38,5 +38,11 @@ Cargo.lock
# Ajonaikaiset tietokannat # Ajonaikaiset tietokannat
*.db *.db
# Lokitiedostot
*.log
# Wanha versio # Wanha versio
temp/ temp/
# Muut
zipit/**

157
TEMPLATING.md Normal file
View File

@@ -0,0 +1,157 @@
# Templating — rakennuspalaset koodigeneroinnissa
## Perusperiaate
Kielimalli päättää **mitä** rakennetaan (entiteetit, kentät, tyypit, yhteydet).
Template-funktiot päättävät **miten** se rakennetaan (importit, engine setup, testikonfiguraatio).
```
Projektikuvaus → LLM → JSON-speksi → Templateit → Koodi → Validointi
```
LLM:n kontribuutio on yksi JSON-rakenne. Kaikki muu on determinististä —
sama speksi tuottaa aina saman koodin.
## Miksi tämä toimii
Pienen kielimallin (0.5B7B) vahvuudet ja heikkoudet ovat epäsymmetrisiä:
| Tehtävä | LLM:n kyky | Ratkaisu |
|---------|-----------|----------|
| Tunnista entiteetit kuvauksesta | Hyvä | LLM tekee |
| Valitse kenttätyypit | Hyvä | LLM tekee |
| Muista importit oikein | Huono | Template tekee |
| SQLite connect_args | Huono | Template tekee |
| Testikonfiguraatio | Huono | Template tekee |
| Dockerfile-rakenne | Huono | Template tekee |
Annetaan mallin tehdä se missä se on hyvä. Hoidetaan loput mekaanisesti.
## JSON-speksi
Kielimallin ainoa tuotos on JSON joka kuvaa projektin rakenteen:
```json
{
"project_name": "library-app",
"entities": [
{
"name": "Author",
"table_name": "authors",
"fields": [
{"name": "name", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null}
]
},
{
"name": "Book",
"table_name": "books",
"fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null},
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false, "default": null}
]
}
],
"relationships": [
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
],
"extra_imports": []
}
```
Speksin laatu ratkaisee kaiken. Hyvä speksi → hyvä projekti. Huono speksi →
teknisesti toimiva mutta sisällöllisesti väärä projekti.
## Architect-promptin rooli
Architect-agentti (JSON-speksin generoija) on kriittisin kohta koko pipelinessa.
Sitä ohjataan neljällä keinolla:
1. **Chain-of-thought** — malli miettii ensin entiteetit, sitten kentät,
sitten yhteydet, vasta lopuksi JSON
2. **Domain-esimerkit** — Todo, verkkokauppa, blogi — malli näkee miltä
hyvä speksi näyttää eri domaineissa
3. **Anti-patternit** — turhat ID-kentät, Enum-tyypit, suomenkieliset nimet
4. **Yhteyssäännöt** — jokainen `_id`-kenttä tarvitsee relationship-merkinnän
Isompi malli tässä yhdessä kohdassa parantaisi kaikkien projektien laatua.
## Templateit
Jokainen template on funktio joka ottaa speksin ja palauttaa koodia:
```
tmplModels(spec) → models.py (SQLAlchemy, ForeignKey, relationship)
tmplSchemas(spec) → schemas.py (Pydantic Create/Response/Detail)
tmplMain(spec) → main.py (FastAPI CRUD + nested endpoints + FK-validointi)
tmplTests(spec) → test_main.py (pytest + TestClient + helper-funktiot)
tmplPyproject(spec) → pyproject.toml (PEP 621)
tmplDockerfile() → Dockerfile (uv + non-root user)
```
Templateit generoivat automaattisesti:
- ForeignKey-constraintit ja relationship()-määrittelyt
- Nested endpointit (`GET /authors/{id}/books/`)
- FK-validointi (404 jos parent-entiteettiä ei ole)
- Detail-schemat (Book + author-data mukana)
- Test-helperit jotka luovat parent-entiteetit ensin
- Bad FK -testit (varmistaa että orpo-validointi toimii)
## Validointi
Generoitu koodi validoidaan mekaanisesti ennen käyttöä:
- Syntaksitarkistus (AST parse)
- Projektin sisäiset importit (löytyykö nimi lähdetiedostosta)
- SQLite connect_args
- Relatiiviset importit (kielletty)
- Testien rakenne (ei saa kopioida appia)
- pyproject.toml (ei poetryä)
- Dockerfile (ei poetryä, uv cache -oikeudet)
Docker-testi ajaa koko projektin: build → pytest → API smoke test.
## Rajoitukset
Templateit kattavat rakenteellisesti tunnetut projektit:
| Stack | Kattavuus |
|-------|-----------|
| FastAPI + SQLAlchemy CRUD | Toimii hyvin |
| Streamlit + DuckDB dashboard | Toimii hyvin |
| Muu | Ei templatea → ei toimi |
**Ei kata:**
- Custom business-logiikka (algoritmit, laskenta, ML)
- Epätyypilliset arkkitehtuurit (WebSocket, graafit, tapahtumapohjaiset)
- Frontend-sovellukset (React, Vue)
- Mikä tahansa mitä template ei tunne
Arvio: templateit kattavat ~20% kaikista mahdollisista projekteista, mutta juuri
sen 20% mitä opiskelu- ja prototyyppiympäristöissä tarvitaan useimmin.
## Laajentaminen
Uuden stackin lisääminen vaatii:
1. Uudet template-funktiot (käsityö, ~200400 riviä per stack)
2. JSON-speksin laajennos (uudet kentät jos tarvitaan)
3. Validointisäännöt uudelle stackille
4. Docker-testikonfiguraatio
Jokainen template on staattinen — se ei opi eikä sopeudu. Kattavuus kasvaa
vain kirjoittamalla lisää templateja.
## Hybridi: seuraava askel
Paras lopputulos syntyisi yhdistelmällä:
```
Speksi → Template (runko) → LLM (business-logiikka) → Validointi
```
Template tuottaa toimivan CRUD-pohjan. LLM lisää domain-kohtaisen logiikan
pienissä palasissa (yksi funktio kerrallaan). Mekaaninen validointi
tarkistaa jokaisen lisäyksen.
Tämä palauttaa LLM:n epäluotettavuuden takaisin peliin, mutta rajattuna:
virheet ovat paikallisia (yksi funktio) eivätkä rakenteellisia (koko projekti).

131
kipina-node Executable file
View File

@@ -0,0 +1,131 @@
#!/bin/bash
# Kipinä Node — lataa oikea binääri ja käynnistä
set -e
BASE_URL="https://kipina.studio/download"
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
# Tunnista OS ja arkkitehtuuri
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS-$ARCH" in
darwin-arm64) BINARY="kipina-node-macos-arm64" ;;
darwin-x86_64) BINARY="kipina-node-macos-arm64" ;; # Rosetta
linux-x86_64) BINARY="kipina-node-linux-x86_64" ;;
linux-aarch64) BINARY="kipina-node-linux-arm64" ;;
*) echo "Ei tuettu: $OS-$ARCH"; exit 1 ;;
esac
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ Kipinä Agentic Node ║"
echo " ╚══════════════════════════════════════╝"
echo ""
echo " OS: $OS ($ARCH)"
echo ""
# Etsi Ollama-instanssit
CANDIDATES=(
"http://localhost:11434"
"http://127.0.0.1:11434"
"http://ollama:11434"
"http://host.docker.internal:11434"
)
# Lisää OLLAMA_URL listaan jos asetettu ja ei jo mukana
if [ -n "$OLLAMA_URL" ]; then
ALREADY=false
for c in "${CANDIDATES[@]}"; do
[ "$c" = "$OLLAMA_URL" ] && ALREADY=true
done
$ALREADY || CANDIDATES=("$OLLAMA_URL" "${CANDIDATES[@]}")
fi
echo " Etsitään Ollama-instansseja..."
FOUND=()
for url in "${CANDIDATES[@]}"; do
if curl -s --connect-timeout 1 "$url/api/tags" &>/dev/null; then
FOUND+=("$url")
fi
done
if [ ${#FOUND[@]} -eq 0 ]; then
# Ei löytynyt — yritä käynnistää lokaali
if command -v ollama &>/dev/null; then
echo " Käynnistetään Ollama..."
ollama serve &>/dev/null &
sleep 3
if curl -s --connect-timeout 1 "http://localhost:11434/api/tags" &>/dev/null; then
OLLAMA_URL="http://localhost:11434"
echo " ✓ Ollama käynnistetty ($OLLAMA_URL)"
else
echo " ✗ Ollaman käynnistys epäonnistui."
exit 1
fi
else
echo ""
echo " ✗ Ollamaa ei löytynyt."
echo " Kontti/remote: OLLAMA_URL=http://HOST:11434 ./kipina-node"
echo " Asenna: curl -fsSL https://ollama.ai/install.sh | sh"
exit 1
fi
elif [ ${#FOUND[@]} -eq 1 ]; then
OLLAMA_URL="${FOUND[0]}"
echo " ✓ Ollama löytyi: $OLLAMA_URL"
else
echo ""
echo " Löytyi ${#FOUND[@]} Ollama-instanssia:"
echo ""
for i in "${!FOUND[@]}"; do
echo " $((i+1))) ${FOUND[$i]}"
done
echo ""
read -p " Valitse [1-${#FOUND[@]}]: " -r CHOICE
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le ${#FOUND[@]} ]; then
OLLAMA_URL="${FOUND[$((CHOICE-1))]}"
else
OLLAMA_URL="${FOUND[0]}"
echo " Käytetään oletusta: $OLLAMA_URL"
fi
echo " ✓ Valittu: $OLLAMA_URL"
fi
echo ""
echo " Hub: $HUB_URL"
echo " Ollama: $OLLAMA_URL"
if [ -n "$KIPINA_MODEL" ]; then
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
fi
# Lataa binääri
BIN_PATH="./kipina-node-bin"
if [ -f "$BIN_PATH" ]; then
echo ""
read -p " Löydettiin vanha kipina-node-bin lokaalisti. Haluatko poistaa sen ja ladata uusimman version? [Y/n] " -r DEL_CHOICE
if [[ "$DEL_CHOICE" =~ ^[Nn]$ ]]; then
echo " ✓ Käytetään lokaalia versiota."
else
rm -f "$BIN_PATH"
echo " ✓ Vanha binääri poistettu ja korvataan uudella."
fi
fi
if [ ! -f "$BIN_PATH" ]; then
echo " Ladataan tuorein $BINARY..."
curl -sSL "$BASE_URL/$BINARY" -o "$BIN_PATH"
chmod +x "$BIN_PATH"
fi
echo ""
echo " ✓ Siirrytään Kipinä Noden hallintaan..."
echo " Ctrl+C pysäyttää"
echo ""
if [ -n "$KIPINA_MODEL" ]; then
export OLLAMA_MODEL="$KIPINA_MODEL"
fi
export HUB_URL="$HUB_URL"
export OLLAMA_URL="$OLLAMA_URL"
exec "$BIN_PATH"

BIN
kipina-node-bin Executable file

Binary file not shown.

View File

@@ -3,26 +3,29 @@
# --- Vaihe 1: Frontend (Astro) --- # --- Vaihe 1: Frontend (Astro) ---
FROM node:22-slim AS frontend FROM node:22-slim AS frontend
WORKDIR /app/frontend WORKDIR /app/frontend
# Riippuvuudet ensin → cache-kerros (muuttuu harvoin)
COPY frontend/package.json frontend/package-lock.json* ./ COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install --silent RUN npm install --silent
# Lähdekoodi → muuttuu usein, mutta npm install on cachessa # Cache-buster: git hash pakottaa rebuildin kun koodi muuttuu
COPY frontend/ . ARG CACHEBUST=0
COPY frontend/src/ ./src/
COPY frontend/public/ ./public/
COPY frontend/astro.config.mjs frontend/tsconfig.json ./
RUN npm run build RUN npm run build
# --- Vaihe 2: Wasm (wasm-pack) --- # --- Vaihe 2: Wasm (wasm-pack) ---
# Cargo registry cachetetaan mount-cachella, lähdekoodi kopioidaan tuoreena
FROM rust:slim AS wasm-builder FROM rust:slim AS wasm-builder
RUN apt-get update && apt-get install -y curl pkg-config libssl-dev g++ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl pkg-config libssl-dev g++ && rm -rf /var/lib/apt/lists/*
RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
WORKDIR /app WORKDIR /app
COPY Cargo.toml Cargo.lock* ./ COPY Cargo.toml Cargo.lock* ./
COPY node/Cargo.toml node/Cargo.toml COPY node/Cargo.toml node/Cargo.toml
COPY node/src node/src
# Dummy-cratet jotta workspace Cargo.toml on tyytyväinen
COPY hub/Cargo.toml hub/Cargo.toml COPY hub/Cargo.toml hub/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 COPY cli/Cargo.toml cli/Cargo.toml
RUN mkdir -p hub/src native-node/src cli/src && touch hub/src/main.rs native-node/src/main.rs cli/src/main.rs RUN mkdir -p hub/src native-node/src cli/src && touch hub/src/main.rs native-node/src/main.rs cli/src/main.rs
ARG CACHEBUST=0
COPY node/src node/src
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \ --mount=type=cache,target=/app/target \
cd node && wasm-pack build --target web --out-dir /app/wasm-pkg cd node && wasm-pack build --target web --out-dir /app/wasm-pkg
@@ -33,12 +36,12 @@ RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/li
WORKDIR /app WORKDIR /app
COPY Cargo.toml Cargo.lock* ./ COPY Cargo.toml Cargo.lock* ./
COPY hub/Cargo.toml hub/Cargo.toml COPY hub/Cargo.toml hub/Cargo.toml
COPY hub/src hub/src
# Tarvitaan dummy-cratet jotta workspace kompiloi
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 COPY cli/Cargo.toml cli/Cargo.toml
RUN mkdir -p node/src native-node/src cli/src && touch node/src/lib.rs native-node/src/main.rs cli/src/main.rs RUN mkdir -p node/src native-node/src cli/src && touch node/src/lib.rs native-node/src/main.rs cli/src/main.rs
ARG CACHEBUST=0
COPY hub/src hub/src
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p hub \ cargo build --release -p hub \
@@ -52,11 +55,6 @@ COPY --from=hub-builder /usr/local/bin/hub /usr/local/bin/hub
COPY --from=frontend /app/frontend/dist /app/frontend/dist COPY --from=frontend /app/frontend/dist /app/frontend/dist
COPY --from=wasm-builder /app/wasm-pkg /app/frontend/dist/pkg COPY --from=wasm-builder /app/wasm-pkg /app/frontend/dist/pkg
# Kopioidaan GUIDE.md ja templates
COPY frontend/public/GUIDE.md /app/frontend/dist/GUIDE.md
COPY frontend/public/templates /app/frontend/dist/templates
COPY frontend/public/avatars /app/frontend/dist/avatars
WORKDIR /app WORKDIR /app
ENV STATIC_DIR=/app/frontend/dist ENV STATIC_DIR=/app/frontend/dist
EXPOSE 3000 EXPOSE 3000

View File

@@ -1,38 +0,0 @@
#!/bin/bash
# Käännä kipina-node binäärit kaikille alustoille
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUT="$SCRIPT_DIR/frontend/public/download"
mkdir -p "$OUT"
echo "=== Kipinä Node — Binary Build ==="
# macOS ARM (natiivi)
echo "[1/3] macOS ARM64..."
cd "$SCRIPT_DIR"
cargo build --release -p native-node --no-default-features 2>&1 | tail -1
cp target/release/native-node "$OUT/kipina-node-macos-arm64"
echo " $(ls -lh "$OUT/kipina-node-macos-arm64" | awk '{print $5}')"
# Linux x86_64 (Docker)
echo "[2/3] Linux x86_64..."
docker run --rm \
-v "$SCRIPT_DIR":/app -w /app \
--platform linux/amd64 \
rust:slim \
bash -c "apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev >/dev/null 2>&1 && cargo build --release -p native-node --no-default-features 2>&1 | tail -1 && cp target/release/native-node /app/frontend/public/download/kipina-node-linux-x86_64"
echo " $(ls -lh "$OUT/kipina-node-linux-x86_64" | awk '{print $5}')"
# Linux ARM64 (Docker)
echo "[3/3] Linux ARM64..."
docker run --rm \
-v "$SCRIPT_DIR":/app -w /app \
--platform linux/arm64 \
rust:slim \
bash -c "apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev >/dev/null 2>&1 && cargo build --release -p native-node --no-default-features 2>&1 | tail -1 && cp target/release/native-node /app/frontend/public/download/kipina-node-linux-arm64"
echo " $(ls -lh "$OUT/kipina-node-linux-arm64" | awk '{print $5}')"
echo ""
echo "=== Binäärit valmiina ==="
ls -lh "$OUT"/kipina-node-*

View File

@@ -1,28 +0,0 @@
#!/bin/bash
# Nopea deploy: päivittää vain frontendin (ei kontin uudelleenkäynnistystä)
# Hub-binäärin päivitys: käytä deploy.sh tai deploy-light.sh
set -e
SERVER="ubuntu@86.50.252.98"
REMOTE_DIR="~/code/agentic-studio/network-poc"
SSH_OPTS="-o StrictHostKeyChecking=no"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== Kipinä Studio — Frontend Deploy ==="
# 1. Buildaa frontend paikallisesti
echo "[1/2] Rakennetaan frontend..."
cd "$SCRIPT_DIR/frontend"
[ -d node_modules ] || npm install --silent
npm run build --silent 2>&1 | tail -1
# 2. Synkataan dist/ palvelimelle (vain muuttuneet tiedostot)
echo "[2/2] Synkataan dist/ → palvelin..."
ssh $SSH_OPTS $SERVER "mkdir -p $REMOTE_DIR/frontend/dist"
rsync -az --delete -e "ssh $SSH_OPTS" "$SCRIPT_DIR/frontend/dist/" "$SERVER:$REMOTE_DIR/frontend/dist/"
echo ""
echo "=== Valmis! Frontend päivitetty — ei uudelleenkäynnistystä ==="
echo " https://kipina.studio"
echo ""
echo "Huom: Jos Rust-koodi (hub/) muuttui, aja: ./deploy.sh"

View File

@@ -1,33 +0,0 @@
#!/bin/bash
# Kevyt deploy: lähetetään vain koodi, palvelin buildaa itse
set -e
SERVER="ubuntu@86.50.252.98"
REMOTE_DIR="~/code/agentic-studio/network-poc"
SSH_OPTS="-o StrictHostKeyChecking=no"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== Kipinä Studio Deploy (remote build) ==="
# 1. Synkataan koodi palvelimelle (vain muuttuneet tiedostot)
echo "[1/3] Synkataan koodi..."
rsync -az --delete \
--exclude 'target/' \
--exclude 'node_modules/' \
--exclude 'dist/' \
--exclude '.astro/' \
--exclude 'temp/' \
--exclude '*.db' \
--exclude '.git/' \
"$SCRIPT_DIR/" "$SERVER:$REMOTE_DIR/"
# 2. Rakennetaan image palvelimella
echo "[2/3] Rakennetaan image palvelimella..."
ssh $SSH_OPTS $SERVER "cd $REMOTE_DIR && docker build -f Dockerfile.prod -t kipina-agentic:latest ."
# 3. Käynnistetään
echo "[3/3] Käynnistetään..."
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 ==="

56
network-poc/deploy-local.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
# Kipinä Studio — paikallinen kehitysympäristö
# Buildaa frontendin, käynnistää hubin ja native-noden (Ollama)
# Käyttö: ./deploy-local.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
cleanup() { echo ""; echo "Pysäytetään..."; kill $HUB_PID $NODE_PID 2>/dev/null; exit 0; }
trap cleanup INT TERM
# Portti vapaaksi
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
# Frontend
echo "[1/3] Frontend..."
cd "$SCRIPT_DIR/frontend"
[ -d node_modules ] || npm install --silent
npm run build 2>&1 | tail -1
cd "$SCRIPT_DIR"
# Hub
echo "[2/3] Hub..."
STATIC_DIR="$SCRIPT_DIR/frontend/dist" cargo run -p hub 2>&1 &
HUB_PID=$!
until curl -sf http://localhost:3000 >/dev/null 2>&1; do sleep 1; done
# Native-node
NODE_PID=""
if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then
MODEL=$(curl -s http://localhost:11434/api/tags | python3 -c "
import sys,json
ms=json.load(sys.stdin).get('models',[])
for m in ms:
n=m['name']
if '7b' in n and 'coder' in n: print(n); exit()
for m in ms:
if 'coder' in m['name']: print(m['name']); exit()
if ms: print(ms[0]['name'])
" 2>/dev/null)
if [ -n "$MODEL" ]; then
echo "[3/3] Native-node ($MODEL)..."
HUB_URL=ws://localhost:3000/ws OLLAMA_MODEL="$MODEL" \
cargo run -p native-node --no-default-features 2>&1 &
NODE_PID=$!
else
echo "[3/3] Ollama: ei malleja (ollama pull qwen2.5-coder:7b)"
fi
else
echo "[3/3] Ei Ollamaa — Wasm-fallback selaimessa"
fi
echo ""
echo "=== http://localhost:3000 === Ctrl+C pysäyttää"
open http://localhost:3000 2>/dev/null || xdg-open http://localhost:3000 2>/dev/null || true
wait $HUB_PID

59
network-poc/deploy-remote.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/bin/bash
# Kipinä Studio — tuotanto-deploy kipina.studioon
# Buildaa Docker-imagen (frontend + hub + wasm) ja vie palvelimelle
# Käyttö: ./deploy-remote.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
SERVER="ubuntu@86.50.252.98"
REMOTE_DIR="~/code/agentic-studio/network-poc"
SSH_OPTS="-o StrictHostKeyChecking=no"
# SSH-avain — yritetään yhdistää, jos ei onnistu, pyydetään avainta
if ! ssh $SSH_OPTS "$SERVER" "echo ok" >/dev/null 2>&1; then
echo "SSH-yhteys ei onnistu, lisätään avain..."
ssh-add "$HOME/.ssh/id_rsa" 2>/dev/null || ssh-add
fi
# Auto-commit
if ! git diff --quiet HEAD 2>/dev/null || \
[ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
echo "Uncommitted muutoksia — commitoidaan..."
read -rp " Commit-viesti: " msg
[ -z "$msg" ] && msg="Deploy $(date +%Y-%m-%d\ %H:%M)"
git add -A && git commit -m "$msg"
fi
echo "=== Kipinä Studio Deploy → kipina.studio ==="
# 1. Docker-image (CACHEBUST pakottaa lähdekoodin uudelleenkopioinnin)
echo "[1/4] Docker build..."
docker build --platform linux/amd64 -f Dockerfile.prod \
--build-arg CACHEBUST="$(git rev-parse HEAD)" \
-t kipina-agentic:latest .
# 2. Pakkaus
echo "[2/4] Pakataan..."
docker save kipina-agentic:latest | gzip > /tmp/kipina-agentic.tar.gz
echo " $(du -h /tmp/kipina-agentic.tar.gz | cut -f1)"
# 3. Siirto
echo "[3/4] Siirretään..."
scp $SSH_OPTS /tmp/kipina-agentic.tar.gz "$SERVER:/tmp/"
scp $SSH_OPTS docker-compose.prod.yml Caddyfile.prod "$SERVER:$REMOTE_DIR/"
# 4. Käynnistys
echo "[4/4] Käynnistetään..."
ssh $SSH_OPTS "$SERVER" "gunzip -c /tmp/kipina-agentic.tar.gz | docker load && rm /tmp/kipina-agentic.tar.gz"
ssh $SSH_OPTS "$SERVER" "cd $REMOTE_DIR && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d"
# Discord
WEBHOOK="https://discord.com/api/webhooks/1489504066898755687/8U02d0wug-3MkVax0xMmRoj0s_-V1psnNLPWdSOjnGnKRBUpPjaU6XiX9Iu8DgJI69AP"
HASH=$(git log -1 --pretty=format:"%h" 2>/dev/null || echo "?")
MSG=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "?")
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'content':sys.argv[1]}))" \
"🚀 **Kipinä Studio julkaistu!** \`${HASH}\` ${MSG} https://kipina.studio")
curl -sf -H "Content-Type: application/json" -d "$PAYLOAD" "$WEBHOOK" >/dev/null || true
echo "=== Valmis! https://kipina.studio ==="

View File

@@ -1,70 +0,0 @@
#!/bin/bash
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"
REMOTE_DIR="~/code/agentic-studio/network-poc"
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 ==="
# 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
echo "[1/4] Rakennetaan image lokaalisti..."
docker build --platform linux/amd64 -f Dockerfile.prod -t kipina-agentic:latest .
# 2. Tallennetaan tiedostoon
echo "[2/5] Pakataan image..."
docker save kipina-agentic:latest | gzip > /tmp/kipina-agentic.tar.gz
echo " Koko: $(du -h /tmp/kipina-agentic.tar.gz | cut -f1)"
# 3. Siirretään palvelimelle
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/
# 4. Ladataan image ja käynnistetään
echo "[4/5] Ladataan image palvelimella..."
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 ==="
# 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

View File

@@ -24,7 +24,6 @@ services:
- NODE_API_KEY=${NODE_API_KEY:-} - NODE_API_KEY=${NODE_API_KEY:-}
volumes: volumes:
- hub_data:/data - hub_data:/data
- ./frontend/dist:/app/frontend/dist:ro
volumes: volumes:
caddy_data: caddy_data:

View File

@@ -230,6 +230,188 @@ mitä luokkia importata.
--- ---
## Rakennuspalaset vs. vapaa generointi
Kielimalli voi generoida koodia kahdella perustavanlaatuisesti eri tavalla.
Ymmärtäminen milloin kumpikin toimii on avain luotettavaan koodigenerointi-pipelineen.
### Tapa 1: Vapaa generointi (naivi)
LLM generoi jokaisen tiedoston tyhjästä. Prompti kuvaa mitä halutaan,
malli tuottaa koko tiedoston — importeista lähtien.
```mermaid
flowchart LR
P["Prompti"] --> LLM1["LLM: models.py"]
LLM1 --> V1{"Validointi"}
V1 -->|virhe| LLM1
V1 -->|ok| LLM2["LLM: schemas.py"]
LLM2 --> V2{"Validointi"}
V2 -->|virhe| LLM2
V2 -->|ok| LLM3["LLM: main.py"]
LLM3 --> V3{"..."}
style V1 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
style V2 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
style V3 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
```
**Ongelma:** Pieni malli (0.5B7B) tekee toistuvia rakenteellisia virheitä:
| Virhe | Esiintymistiheys | Selitys |
|-------|:---:|------|
| Puuttuva import | ~60% | `from datetime import date` unohtuu |
| SQLite `connect_args` | ~80% | Malli ei muista SQLite-erityisyyttä |
| Väärä Enum-käyttö | ~50% | Sekoittaa `sqlalchemy.Enum` ja `enum.Enum` |
| Poetry pyproject.toml:ssa | ~40% | Malli suosii Poetryä vaikka ohje sanoo uv |
| Testit kopioivat koko appin | ~70% | Malli ei osaa importata, luo uudet reitit |
Retry-loopilla (virhe → uusi yritys virheviestin kanssa) osa korjautuu,
mutta **sama malli toistaa samoja virheitä** koska ne johtuvat harjoitusdatasta.
7 tiedoston projekti vaatii 714 LLM-kutsua ja 80120 sekuntia.
### Tapa 2: Rakennuspalaset (template pipeline)
LLM:ltä pyydetään **vain JSON-speksi** — entiteetit, kentät ja tyypit.
Koodi kootaan mekaanisesti valmiista pohjista joiden rakenne on todistettavasti
oikein.
```mermaid
flowchart LR
P["Projektin kuvaus"] --> LLM["LLM: JSON-speksi"]
LLM --> S["{ entities: [...] }"]
S --> T1["Template: models.py"]
S --> T2["Template: schemas.py"]
S --> T3["Template: main.py"]
S --> T4["Template: test_main.py"]
S --> T5["Template: Dockerfile"]
T1 & T2 & T3 & T4 & T5 --> D["Docker build + pytest"]
style LLM fill:#1a1e2e,stroke:#d29922,color:#c9d1d9
style S fill:#1a1e2e,stroke:#3fb950,color:#c9d1d9
style D fill:#1a1e2e,stroke:#58a6ff,color:#c9d1d9
```
**Idea:** Malli on hyvä päättämään *mitä* (entiteetit, kentät), mutta huono
muistamaan *miten* (importit, engine setup, testikonfiguraatio). Annetaan
mallin tehdä se missä se on hyvä, ja hoidetaan loput mekaanisesti.
### LLM:n ainoa tehtävä
Malli tuottaa JSON-rakenteen kuten:
```json
{
"project_name": "todo-app",
"entities": [
{
"name": "Todo",
"table_name": "todos",
"fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false},
{"name": "due_date", "sa_type": "Date", "py_type": "date | None", "nullable": true},
{"name": "status", "sa_type": "String(20)", "py_type": "str", "default": "pending"}
]
}
],
"extra_imports": ["from datetime import date"]
}
```
Tämä on yksinkertainen tehtävä jossa pienikin malli onnistuu luotettavasti:
entiteettien tunnistus projektin kuvauksesta ja kenttätyyppien valinta.
Speksi sisältää myös **taulujen väliset yhteydet** (relationships):
```json
{
"entities": [
{"name": "Author", "table_name": "authors", "fields": [...]},
{"name": "Book", "table_name": "books", "fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false},
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false}
]}
],
"relationships": [
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
]
}
```
Templateit generoivat yhteyksistä automaattisesti:
- `ForeignKey('authors.id')` models.py:hin
- `relationship("Book", back_populates="author")` molempiin suuntiin
- `BookDetail`-schema jossa author-data mukana
- `GET /authors/{id}/books/` nested endpoint
- FK-validointi: 404 jos parent-entiteettiä ei ole
### Architect-agentti: speksin laatu ratkaisee
Arkkitehti on **kriittisin agentti** koko pipelinessa. Jos speksi on hyvä
(oikeat taulut, kentät, yhteydet), kaikki muu seuraa automaattisesti.
Jos speksi on huono, templateitkaan eivät pelasta.
Arkkitehtia ohjataan:
1. **Chain-of-thought**: "Mieti ensin taulut, sitten kentät, sitten yhteydet"
2. **Domain-esimerkit**: Todo, verkkokauppa, blogi — malli näkee miltä hyvä speksi näyttää
3. **Anti-patternit**: "Ei turhia ID-kenttiä, ei Enumeita, ei suomenkielisiä nimiä koodissa"
4. **Yhteyssäännöt**: "Jokainen `_id`-kenttä tarvitsee vastaavan relationship-merkinnän"
Isompi malli (tai API) tässä yhdessä kohdassa parantaa kaikkien projektien laatua
koska speksi on ainoa paikka jossa LLM:n ymmärrys vaikuttaa.
### Template täyttää loput
Jokainen template on kuin madlib — aukot täytetään speksin datalla:
**models.py template (yksinkertaistettu):**
```python
from sqlalchemy import create_engine, Column, Integer, {sa_types}, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship
# ... aina samat importit, engine setup, SessionLocal ...
class {entity.name}(Base):
__tablename__ = "{entity.table_name}"
id = Column(Integer, primary_key=True, index=True)
{field.name} = Column({field.sa_type}, nullable={field.nullable})
# FK-kentät: ForeignKey + relationship automaattisesti
{fk_field} = Column(Integer, ForeignKey('{parent_table}.id'))
{parent_lower} = relationship("{Parent}", back_populates="{children}")
```
Tulos: importit ovat aina oikein, `connect_args` on aina mukana,
taulujen yhteydet generoituvat oikein, testit importoivat `main.py`:stä eivätkä kopioi sitä.
### Vertailu: mittaustulokset
| | Vapaa generointi | Rakennuspalaset |
|---|:---:|:---:|
| LLM-kutsuja | 714 | **3** (speksi + requirements + README) |
| Aika | 80120s | **~25s** |
| Syntaksi OK | ~70% | **100%** |
| Docker build | vaihteleva | **100%** |
| Pytest läpi | 0% | **100%** |
| API toimii | ~30% | **100%** |
| Taulujen yhteydet (FK) | ei koskaan | **100%** |
| Nested endpointit | ei koskaan | **automaattisesti** |
### Milloin kumpikin toimii
**Rakennuspalaset** kun:
- Projektin rakenne on tunnettu (FastAPI + SQLAlchemy CRUD)
- Laatu ja luotettavuus ovat tärkeitä
- Malli on pieni (0.5B7B)
**Vapaa generointi** kun:
- Projektin rakenne on epätavallinen
- Tarvitaan custom-logiikkaa jota template ei kata
- Malli on riittävän iso (>70B tai pilvi-API)
Paras lopputulos syntyy yhdistelmällä: **rakennuspalaset perusrakenteelle,
vapaa generointi business-logiikalle**.
---
## Laadun parantaminen ## Laadun parantaminen
### 1. Isompi malli (suurin vaikutus) ### 1. Isompi malli (suurin vaikutus)

View File

@@ -0,0 +1 @@
cf3bf54

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -4,7 +4,6 @@ set -e
BASE_URL="https://kipina.studio/download" BASE_URL="https://kipina.studio/download"
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}" HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
MODEL="${KIPINA_MODEL:-qwen2.5-coder:3b}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}" OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
# Tunnista OS ja arkkitehtuuri # Tunnista OS ja arkkitehtuuri
@@ -96,26 +95,41 @@ fi
echo "" echo ""
echo " Hub: $HUB_URL" echo " Hub: $HUB_URL"
echo " Ollama: $OLLAMA_URL" echo " Ollama: $OLLAMA_URL"
echo " Malli: $MODEL" if [ -n "$KIPINA_MODEL" ]; then
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
# Lataa malli (toimii sekä lokaalilla binäärillä että API:n kautta)
if ! curl -s "$OLLAMA_URL/api/tags" | grep -q "$MODEL"; then
echo " Ladataan $MODEL..."
curl -s "$OLLAMA_URL/api/pull" -d "{\"name\":\"$MODEL\"}" > /dev/null
fi fi
echo " ✓ Malli $MODEL valmis"
# Lataa binääri # Binäärin automaattinen päivitys — vertaa build-hashia palvelimeen
BIN_PATH="./kipina-node-bin" BIN_PATH="./kipina-node-bin"
if [ ! -f "$BIN_PATH" ]; then HASH_PATH="./kipina-node-bin.hash"
echo " Ladataan $BINARY..."
curl -sSL "$BASE_URL/$BINARY" -o "$BIN_PATH" REMOTE_HASH=$(curl -sSL "$BASE_URL/.build-hash?v=$(date +%s)" 2>/dev/null | tr -d '[:space:]')
LOCAL_HASH=""
[ -f "$HASH_PATH" ] && LOCAL_HASH=$(cat "$HASH_PATH" | tr -d '[:space:]')
if [ -f "$BIN_PATH" ] && [ -n "$REMOTE_HASH" ] && [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
echo " ✓ Binääri ajan tasalla (versio: $LOCAL_HASH)"
else
if [ -f "$BIN_PATH" ]; then
echo " ↻ Uusi versio saatavilla ($LOCAL_HASH → $REMOTE_HASH)"
else
echo " Ladataan $BINARY..."
fi
rm -f "$BIN_PATH"
curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
chmod +x "$BIN_PATH" chmod +x "$BIN_PATH"
echo "$REMOTE_HASH" > "$HASH_PATH"
echo " ✓ Päivitetty versioon $REMOTE_HASH"
fi fi
echo "" echo ""
echo " ✓ Yhdistetään laskentaverkkoon..." echo " ✓ Siirrytään Kipinä Noden hallintaan..."
echo " Ctrl+C pysäyttää" echo " Ctrl+C pysäyttää"
echo "" echo ""
HUB_URL="$HUB_URL" OLLAMA_URL="$OLLAMA_URL" OLLAMA_MODEL="$MODEL" exec "$BIN_PATH" if [ -n "$KIPINA_MODEL" ]; then
export OLLAMA_MODEL="$KIPINA_MODEL"
fi
export HUB_URL="$HUB_URL"
export OLLAMA_URL="$OLLAMA_URL"
exec "$BIN_PATH"

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -0,0 +1,33 @@
{
"name": "Data Analytics Pipeline",
"description": "ETL, analysis, and visualization with Docker (MariaDB + Jupyter)",
"keywords": ["data", "analytics", "csv", "etl", "visualization", "statistics", "dashboard", "jupyter", "pandas", "matplotlib"],
"files": {
"etl.py": {
"description": "Data loading, cleaning, and transformation",
"example": "import pandas as pd\nfrom pathlib import Path\nfrom sqlalchemy import create_engine\n\nDB_URL = \"mysql+pymysql://root:secret@localhost:3306/analytics\"\nengine = create_engine(DB_URL)\n\ndef load_csv(path: str) -> pd.DataFrame:\n df = pd.read_csv(path)\n print(f\"Loaded {len(df)} rows from {path}\")\n return df\n\ndef clean(df: pd.DataFrame) -> pd.DataFrame:\n df = df.dropna(subset=[\"x\", \"y\"])\n df = df[(df[\"x\"] >= 0) & (df[\"y\"] >= 0)] # Remove outliers\n df[\"timestamp\"] = pd.to_datetime(df[\"timestamp\"])\n return df.sort_values(\"timestamp\").reset_index(drop=True)\n\ndef to_database(df: pd.DataFrame, table: str):\n df.to_sql(table, engine, if_exists=\"replace\", index=False)\n print(f\"Wrote {len(df)} rows to {table}\")\n\nif __name__ == \"__main__\":\n for csv_file in sorted(Path(\"data\").glob(\"*.csv\")):\n df = load_csv(str(csv_file))\n df = clean(df)\n to_database(df, \"measurements\")",
"instructions": "Write the ETL pipeline:\n- Load CSV files from data/ directory using pandas\n- Clean: remove nulls, filter outliers, parse timestamps\n- Transform: convert units, compute derived columns\n- Load into MariaDB via SQLAlchemy\n- Make it runnable as a standalone script"
},
"analysis.py": {
"description": "Statistical analysis and metrics computation",
"example": "import pandas as pd\nfrom sqlalchemy import create_engine\n\nDB_URL = \"mysql+pymysql://root:secret@localhost:3306/analytics\"\nengine = create_engine(DB_URL)\n\ndef load_data() -> pd.DataFrame:\n return pd.read_sql(\"SELECT * FROM measurements\", engine)\n\ndef summary_stats(df: pd.DataFrame) -> dict:\n return {\n \"total_rows\": len(df),\n \"date_range\": f\"{df['timestamp'].min()} to {df['timestamp'].max()}\",\n \"unique_entities\": df[\"entity_id\"].nunique(),\n }\n\ndef hourly_distribution(df: pd.DataFrame) -> pd.DataFrame:\n df[\"hour\"] = df[\"timestamp\"].dt.hour\n return df.groupby(\"hour\").size().reset_index(name=\"count\")\n\nif __name__ == \"__main__\":\n df = load_data()\n stats = summary_stats(df)\n for k, v in stats.items():\n print(f\"{k}: {v}\")",
"instructions": "Write analysis functions:\n- Load cleaned data from MariaDB\n- Compute summary statistics (counts, date ranges, distributions)\n- Time-based analysis (hourly, daily, weekly patterns)\n- Group-level metrics (per entity, per zone)\n- Return DataFrames and dicts suitable for visualization"
},
"visualize.py": {
"description": "Charts and visualizations with matplotlib",
"example": "import matplotlib.pyplot as plt\nimport pandas as pd\nfrom analysis import load_data, hourly_distribution\n\ndef plot_heatmap(df: pd.DataFrame, title: str, output: str):\n fig, ax = plt.subplots(figsize=(12, 8))\n scatter = ax.scatter(df[\"x\"], df[\"y\"], c=df[\"density\"], cmap=\"hot\", alpha=0.5, s=2)\n ax.set_title(title)\n ax.set_xlabel(\"x\")\n ax.set_ylabel(\"y\")\n ax.invert_yaxis()\n plt.colorbar(scatter, label=\"Density\")\n plt.tight_layout()\n plt.savefig(output, dpi=150)\n print(f\"Saved {output}\")\n\ndef plot_bar(df: pd.DataFrame, x: str, y: str, title: str, output: str):\n fig, ax = plt.subplots(figsize=(10, 5))\n ax.bar(df[x], df[y], color=\"steelblue\")\n ax.set_title(title)\n ax.set_xlabel(x)\n ax.set_ylabel(y)\n plt.tight_layout()\n plt.savefig(output, dpi=150)\n\nif __name__ == \"__main__\":\n df = load_data()\n hourly = hourly_distribution(df)\n plot_bar(hourly, \"hour\", \"count\", \"Hourly Distribution\", \"output/hourly.png\")",
"instructions": "Write visualization functions:\n- Import analysis functions for data\n- Heatmaps, bar charts, line charts as appropriate\n- Save figures to output/ directory (PNG, 150 DPI)\n- Use matplotlib with clear titles, labels, colorbars\n- Make it runnable as standalone to generate all charts"
},
"docker-compose.yml": {
"description": "Docker Compose stack for database and Jupyter",
"example": "services:\n db:\n image: mariadb:11\n environment:\n MYSQL_ROOT_PASSWORD: secret\n MYSQL_DATABASE: analytics\n ports:\n - \"3306:3306\"\n volumes:\n - db_data:/var/lib/mysql\n\n jupyter:\n image: jupyter/scipy-notebook:latest\n ports:\n - \"8888:8888\"\n volumes:\n - .:/home/jovyan/work\n environment:\n JUPYTER_TOKEN: kipina\n depends_on:\n - db\n\nvolumes:\n db_data:",
"instructions": "Write docker-compose.yml:\n- MariaDB service with persistent volume\n- JupyterLab service with project mounted\n- Correct environment variables\n- Port mappings for local development\n- Write ONLY the YAML, no explanations"
},
"pyproject.toml": {
"description": "Project dependencies",
"example": "[project]\nname = \"analytics\"\nversion = \"0.1.0\"\nrequires-python = \">=3.11\"\ndependencies = [\n \"pandas\",\n \"matplotlib\",\n \"sqlalchemy\",\n \"pymysql\",\n]\n\n[project.scripts]\netl = \"python etl.py\"\nanalyze = \"python analysis.py\"\nvisualize = \"python visualize.py\"",
"instructions": "Use [project] format (PEP 621). List all data science dependencies. Add scripts for ETL, analysis, and visualization."
}
},
"order": ["etl.py", "analysis.py", "visualize.py", "docker-compose.yml", "pyproject.toml"]
}

View File

@@ -1,6 +1,7 @@
{ {
"name": "FastAPI CRUD", "name": "FastAPI CRUD",
"description": "REST API with SQLite database", "description": "REST API with SQLite database",
"keywords": ["api", "rest", "crud", "endpoint", "fastapi", "web", "backend", "server", "database", "sqlite"],
"files": { "files": {
"models.py": { "models.py": {
"description": "SQLAlchemy models, engine, and session", "description": "SQLAlchemy models, engine, and session",

View File

@@ -1,37 +1,37 @@
<!-- Agenttigalleria + konfigurointipaneeli --> <!-- Agent gallery + configuration panel -->
<div style="display:flex;gap:16px;padding:10px 0;align-items:flex-start"> <div style="display:flex;gap:16px;padding:10px 0;align-items:flex-start">
<!-- Agenttilista (drag & drop) --> <!-- Agenttilista (drag & drop) -->
<div id="agent-bar" style="display:flex;gap:6px;align-items:flex-end;flex-wrap:wrap"> <div id="agent-bar" style="display:flex;gap:6px;align-items:flex-end;flex-wrap:wrap">
<!-- Renderöidään JS:stä --> <!-- Renderöidään JS:stä -->
</div> </div>
<!-- + Lisää agentti --> <!-- + Add agent -->
<div id="add-agent-btn" class="agent-avatar" onclick="addCustomAgent()" title="Lisää oma agentti" style="opacity:0.4"> <div id="add-agent-btn" class="agent-avatar" onclick="addCustomAgent()" title="Add custom agent" style="opacity:0.4">
<div style="width:48px;height:48px;border-radius:50%;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;font-size:24px;color:var(--border)">+</div> <div style="width:48px;height:48px;border-radius:50%;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;font-size:24px;color:var(--border)">+</div>
<span style="font-size:10px;color:#8b949e;text-align:center;display:block">Lisää</span> <span style="font-size:10px;color:#8b949e;text-align:center;display:block">Add</span>
</div> </div>
</div> </div>
<!-- Agentin konfigurointipaneeli (avautuu klikkaamalla avataria) --> <!-- Agent configuration panel (opens clicking avatar) -->
<div id="agent-config" style="display:none;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:10px"> <div id="agent-config" style="display:none;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<img id="config-avatar" src="" style="width:40px;height:40px;border-radius:50%"> <img id="config-avatar" src="" style="width:40px;height:40px;border-radius:50%">
<div> <div>
<input id="config-name" style="background:transparent;border:none;color:var(--text);font-size:16px;font-weight:600;outline:none;width:200px" placeholder="Agentin nimi"> <input id="config-name" style="background:transparent;border:none;color:var(--text);font-size:16px;font-weight:600;outline:none;width:200px" placeholder="Agent Name">
<div id="config-role" style="font-size:11px;color:#8b949e"></div> <div id="config-role" style="font-size:11px;color:#8b949e"></div>
</div> </div>
</div> </div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-red" onclick="deleteAgent()" title="Poista agentti">Poista</button> <button class="btn btn-red" onclick="deleteAgent()" title="Delete agent">Delete</button>
<button class="btn btn-muted" onclick="closeAgentConfig()">Sulje</button> <button class="btn btn-muted" onclick="closeAgentConfig()">Close</button>
</div> </div>
</div> </div>
<!-- Malli --> <!-- Model -->
<div style="margin-bottom:10px"> <div style="margin-bottom:10px">
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Kielimalli</label> <label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Model</label>
<select id="config-model" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:13px;width:100%"> <select id="config-model" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:13px;width:100%">
<option value="qwen-coder">Qwen2.5-Coder:0.5B (selain)</option> <option value="qwen-coder">Qwen2.5-Coder:0.5B (browser)</option>
<option value="qwen-coder-3b">Qwen2.5-Coder:3B (Ollama)</option> <option value="qwen-coder-3b">Qwen2.5-Coder:3B (Ollama)</option>
<option value="qwen2.5-coder:7b">Qwen2.5-Coder:7B (Ollama)</option> <option value="qwen2.5-coder:7b">Qwen2.5-Coder:7B (Ollama)</option>
<option value="qwen2.5-coder:1.5b">Qwen2.5-Coder:1.5B (Ollama)</option> <option value="qwen2.5-coder:1.5b">Qwen2.5-Coder:1.5B (Ollama)</option>
@@ -39,41 +39,41 @@
</div> </div>
<!-- System prompt --> <!-- System prompt -->
<div style="margin-bottom:10px" title="Agentin perusohje joka lähetetään kielimallille jokaisessa pyynnössä.&#10;&#10;Hyvän promptin rakenne:&#10;1. Rooli: 'You are an expert...'&#10;2. Säännöt: RULES/CRITICAL RULES listana&#10;3. Esimerkit: EXAMPLE OUTPUT&#10;4. Kiellot: NEVER-lista&#10;&#10;Vinkki: käytä englantia — malli ymmärtää sen paremmin ja se kuluttaa vähemmän tokeneita."> <div style="margin-bottom:10px" title="System prompt sent to the LLM on every request.&#10;&#10;Good prompt structure:&#10;1. Role: 'You are an expert...'&#10;2. Rules: RULES/CRITICAL RULES as list&#10;3. Examples: EXAMPLE OUTPUT&#10;4. Restrictions: NEVER-list&#10;">
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px;cursor:help">System prompt 💡</label> <label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px;cursor:help">System prompt 💡</label>
<textarea id="config-prompt" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;font-family:'Courier New',monospace;resize:vertical;overflow:hidden;min-height:60px" placeholder="Kuvaa agentin rooli ja käyttäytyminen..."></textarea> <textarea id="config-prompt" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;font-family:'Courier New',monospace;resize:vertical;overflow:hidden;min-height:60px" placeholder="Describe the agent's role and behavior..."></textarea>
</div> </div>
<!-- Sampling-parametrit --> <!-- Sampling Parameters -->
<div style="margin-bottom:10px"> <div style="margin-bottom:10px">
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:8px">Sampling-parametrit</label> <label style="font-size:12px;color:#8b949e;display:block;margin-bottom:8px">Sampling Parameters</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div title="Kontrolloi 'luovuutta'. Matala arvo (0.2-0.4) tuottaa ennustettavaa, toistettavaa koodia — hyvä testaajille ja reviewereille. Keskiarvo (0.6-0.8) on paras koodin generointiin. Korkea arvo (1.0+) lisää vaihtelua mutta myös virheitä.&#10;&#10;Suositus:&#10;• Manageri: 0.5 (tarkat tiedostolistat)&#10;• Koodari: 0.7 (toimiva koodi + vaihtelu)&#10;• Testaaja: 0.3 (deterministinen arviointi)"> <div title="Controls 'creativity'. Low value (0.2-0.4) produces predictable, repeatable code — good for testers and reviewers. Medium value (0.6-0.8) is best for generating code. High value (1.0+) adds variation but also errors.&#10;&#10;Recommendation:&#10;• Manager: 0.5 (precise file lists)&#10;• Coder: 0.7 (working code + variation)&#10;• Tester: 0.3 (deterministic evaluation)">
<label style="font-size:11px;color:#8b949e;cursor:help">Temperature 💡 <span id="config-temp-val" style="color:var(--accent);float:right">0.7</span></label> <label style="font-size:11px;color:#8b949e;cursor:help">Temperature 💡 <span id="config-temp-val" style="color:var(--accent);float:right">0.7</span></label>
<input type="range" id="config-temperature" min="0" max="1.5" step="0.1" value="0.7" style="width:100%;accent-color:var(--accent)"> <input type="range" id="config-temperature" min="0" max="1.5" step="0.1" value="0.7" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">0=tarkka · 0.7=oletus · 1.5=luova</div> <div style="font-size:10px;color:#30363d">0=strict · 0.7=default · 1.5=creative</div>
</div> </div>
<div title="Vastauksen maksimipituus tokeneina (~1 token ≈ 4 merkkiä).&#10;&#10;Suositus:&#10;• Manageri: 256-512 (lyhyet tiedostolistat)&#10;• Koodari: 1024-2048 (täydet tiedostot, CRUD-endpointit)&#10;• Testaaja: 256-512 (lyhyet arvioinnit)&#10;&#10;Jos koodi katkeaa kesken, nosta tätä. Jos malli tuottaa turhaa toistoa, laske."> <div title="Maximum response length in tokens (~1 token ≈ 4 chars).&#10;&#10;Recommendation:&#10;• Manager: 256-512 (short lists)&#10;• Coder: 1024-2048 (full files, CRUD endpoints)&#10;• Tester: 256-512 (short evaluations)&#10;&#10;If code cuts off early, increase this.">
<label style="font-size:11px;color:#8b949e;cursor:help">Max tokens 💡 <span id="config-maxtok-val" style="color:var(--accent);float:right">1024</span></label> <label style="font-size:11px;color:#8b949e;cursor:help">Max tokens 💡 <span id="config-maxtok-val" style="color:var(--accent);float:right">1024</span></label>
<input type="range" id="config-maxtokens" min="64" max="4096" step="64" value="1024" style="width:100%;accent-color:var(--accent)"> <input type="range" id="config-maxtokens" min="64" max="4096" step="64" value="1024" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">Vastauksen maksimipituus</div> <div style="font-size:10px;color:#30363d">Maximum response length</div>
</div> </div>
<div title="Montako todennäköisintä tokenia huomioidaan valinnassa. Pieni arvo (1-10) tekee vastauksesta deterministisen. Suuri arvo (50-100) sallii harvinaisempia sanoja.&#10;&#10;Suositus:&#10;• Boilerplate-koodi: 20-30 (tutut patternit)&#10;• Yleiskoodi: 40 (hyvä oletus)&#10;• Luova teksti: 60-80&#10;&#10;Yleensä ei tarvitse muuttaa oletuksesta."> <div title="How many most probable tokens are considered. Low value (1-10) makes response deterministic. High value (50-100) allows rarer words.&#10;&#10;Recommendation:&#10;• Boilerplate code: 20-30 (familiar patterns)&#10;• General code: 40 (good default)&#10;• Creative text: 60-80">
<label style="font-size:11px;color:#8b949e;cursor:help">Top-K 💡 <span id="config-topk-val" style="color:var(--accent);float:right">40</span></label> <label style="font-size:11px;color:#8b949e;cursor:help">Top-K 💡 <span id="config-topk-val" style="color:var(--accent);float:right">40</span></label>
<input type="range" id="config-topk" min="1" max="100" step="1" value="40" style="width:100%;accent-color:var(--accent)"> <input type="range" id="config-topk" min="1" max="100" step="1" value="40" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">1=greedy · 40=oletus · 100=laaja</div> <div style="font-size:10px;color:#30363d">1=greedy · 40=default · 100=wide</div>
</div> </div>
<div title="Vähentää jo tuotettujen sanojen todennäköisyyttä. Estää mallia toistamasta samaa lausetta. Liian korkea arvo (>1.5) voi rikkoa koodin koska samat avainsanat (return, if, def) ovat tarpeellisia.&#10;&#10;Suositus:&#10;• Koodi: 1.1-1.2 (lievä, sallii toiston)&#10;• Teksti: 1.15-1.3 (vahvempi)&#10;• Review: 1.0-1.1 (ei rangaistusta, lyhyet vastaukset)"> <div title="Reduces the probability of already generated words. Prevents model from repeating same sentences. Too high value (>1.5) can break code because common keywords (return, if, def) are necessary.&#10;&#10;Recommendation:&#10;• Code: 1.1-1.2 (mild, allows repetition)&#10;• Text: 1.15-1.3 (stronger penalty)&#10;• Review: 1.0-1.1 (no penalty, short answers)">
<label style="font-size:11px;color:#8b949e;cursor:help">Repetition penalty 💡 <span id="config-rep-val" style="color:var(--accent);float:right">1.15</span></label> <label style="font-size:11px;color:#8b949e;cursor:help">Repetition penalty 💡 <span id="config-rep-val" style="color:var(--accent);float:right">1.15</span></label>
<input type="range" id="config-repeat" min="1.0" max="2.0" step="0.05" value="1.15" style="width:100%;accent-color:var(--accent)"> <input type="range" id="config-repeat" min="1.0" max="2.0" step="0.05" value="1.15" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">1.0=ei · 1.15=oletus · 2.0=vahva</div> <div style="font-size:10px;color:#30363d">1.0=none · 1.15=default · 2.0=strong</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Pipeline-järjestys --> <!-- Pipeline order -->
<div> <div>
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Pipeline-järjestys <span style="color:var(--border)">(vedä järjestääksesi)</span></label> <label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Pipeline Order <span style="color:var(--border)">(drag to sort)</span></label>
<div id="config-pipeline" style="display:flex;gap:4px;flex-wrap:wrap"></div> <div id="config-pipeline" style="display:flex;gap:4px;flex-wrap:wrap"></div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,11 @@
<!-- Monaco Editor paneeli --> <!-- Monaco Editor paneeli -->
<div id="panel-editor" class="panel"> <div id="panel-editor" class="panel">
<div style="display:flex;height:calc(100vh - 200px);gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden"> <div style="display:flex;flex:1;min-height:0;gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden">
<div id="editor-filetree" style="width:200px;min-width:150px;background:var(--bg);border-right:1px solid var(--border);overflow-y:auto;font-family:'Courier New',monospace;font-size:13px"> <div id="editor-filetree" style="width:200px;min-width:150px;background:var(--bg);border-right:1px solid var(--border);overflow:auto;resize:horizontal;font-family:'Courier New',monospace;font-size:13px">
<div style="padding:10px 12px;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">Tiedostot</div> <div style="padding:10px 12px;color:#8b949e;font-size:11px;display:flex;justify-content:space-between;align-items:center;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">
<span>Tiedostot</span>
<button class="btn btn-green" style="padding:2px 6px;font-size:10px" onclick="downloadProjectZip()">.ZIP</button>
</div>
<div id="editor-file-list" style="padding:4px 0"> <div id="editor-file-list" style="padding:4px 0">
<div style="padding:8px 16px;color:#8b949e;font-size:12px">Generoi projekti:<br><code style="color:var(--accent)">kpn project "..."</code></div> <div style="padding:8px 16px;color:#8b949e;font-size:12px">Generoi projekti:<br><code style="color:var(--accent)">kpn project "..."</code></div>
</div> </div>

View File

@@ -58,6 +58,49 @@
</select> </select>
</div> </div>
<!-- Pipeline-rajoitteet -->
<div class="settings-section">
<h3 class="settings-title">Pipeline-rajoitteet</h3>
<p class="settings-desc">Projektin generoinnin rajat. Suuremmat arvot = rikkaampi output, hitaampi suoritus.</p>
<div class="settings-grid">
<div>
<label class="settings-label">Client: max sanat <span id="set-plc-words-val" class="settings-val">400</span></label>
<input type="range" id="set-plc-words" min="100" max="800" step="50" value="400" class="settings-slider">
<div class="settings-hint">Vaatimusmäärittelyn maksimipituus sanoina</div>
</div>
<div>
<label class="settings-label">Client: max ominaisuudet <span id="set-plc-feats-val" class="settings-val">8</span></label>
<input type="range" id="set-plc-feats" min="3" max="15" step="1" value="8" class="settings-slider">
<div class="settings-hint">Montako ominaisuutta vaatimuksiin</div>
</div>
<div>
<label class="settings-label">Manager: max tiedostot <span id="set-plc-mfiles-val" class="settings-val">8</span></label>
<input type="range" id="set-plc-mfiles" min="3" max="15" step="1" value="8" class="settings-slider">
<div class="settings-hint">Managerin suunnittelemien tiedostojen yläraja</div>
</div>
<div>
<label class="settings-label">Vapaa tila: max tiedostot <span id="set-plc-ffiles-val" class="settings-val">8</span></label>
<input type="range" id="set-plc-ffiles" min="3" max="15" step="1" value="8" class="settings-slider">
<div class="settings-hint">Tiedostoraja kun ei mallipohjaa</div>
</div>
<div>
<label class="settings-label">Review-kierrokset <span id="set-plc-review-val" class="settings-val">3</span></label>
<input type="range" id="set-plc-review" min="1" max="5" step="1" value="3" class="settings-slider">
<div class="settings-hint">Katselmointi-korjaus-syklien max määrä</div>
</div>
<div>
<label class="settings-label">Terminaali: max rivit <span id="set-plc-term-val" class="settings-val">300</span></label>
<input type="range" id="set-plc-term" min="50" max="1000" step="50" value="300" class="settings-slider">
<div class="settings-hint">Terminaalin näyttämien rivien yläraja</div>
</div>
<div>
<label class="settings-label">CrewAI: prompt-rivit <span id="set-plc-crew-val" class="settings-val">50</span></label>
<input type="range" id="set-plc-crew" min="10" max="200" step="10" value="50" class="settings-slider">
<div class="settings-hint">tasks.yaml:n promptin max rivimäärä</div>
</div>
</div>
</div>
<!-- Reset --> <!-- Reset -->
<div style="margin-top:24px;padding-top:16px;border-top:1px solid var(--border)"> <div style="margin-top:24px;padding-top:16px;border-top:1px solid var(--border)">
<button class="btn btn-red" onclick="resetSettings()" style="padding:6px 16px">Palauta oletukset</button> <button class="btn btn-red" onclick="resetSettings()" style="padding:6px 16px">Palauta oletukset</button>

View File

@@ -40,8 +40,8 @@
<div style="padding:12px;background:var(--bg);border-radius:4px;border-left:3px solid var(--green)"> <div style="padding:12px;background:var(--bg);border-radius:4px;border-left:3px solid var(--green)">
<div style="color:#e6edf3;font-weight:600;margin-bottom:6px">2. Käynnistä Kipinä-node</div> <div style="color:#e6edf3;font-weight:600;margin-bottom:6px">2. Käynnistä Kipinä-node</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px"> <div style="display:flex;gap:6px;align-items:center;margin-bottom:6px">
<code style="flex:1;background:#010409;padding:8px 12px;border-radius:4px;color:var(--green);font-family:'Courier New',monospace;font-size:13px;user-select:all">curl -sSL https://kipina.studio/kipina-node -o kipina-node && chmod +x kipina-node && ./kipina-node</code> <code style="flex:1;background:#010409;padding:8px 12px;border-radius:4px;color:var(--green);font-family:'Courier New',monospace;font-size:13px;user-select:all">curl -sSL "https://kipina.studio/kipina-node?v=$(date +%s)" -o kipina-node && chmod +x kipina-node && ./kipina-node</code>
<button onclick="navigator.clipboard.writeText('curl -sSL https://kipina.studio/kipina-node -o kipina-node && chmod +x kipina-node && ./kipina-node');this.textContent='✓';setTimeout(()=>this.textContent='Kopioi',1500)" class="btn btn-green" style="padding:6px 10px">Kopioi</button> <button onclick="navigator.clipboard.writeText('curl -sSL &quot;https://kipina.studio/kipina-node?v=$(date +%s)&quot; -o kipina-node && chmod +x kipina-node && ./kipina-node');this.textContent='✓';setTimeout(()=>this.textContent='Kopioi',1500)" class="btn btn-green" style="padding:6px 10px">Kopioi</button>
</div> </div>
<div style="color:#8b949e;font-size:12px">Lataa kielimallin (~2GB) automaattisesti ensimmäisellä kerralla. Ctrl+C pysäyttää.</div> <div style="color:#8b949e;font-size:12px">Lataa kielimallin (~2GB) automaattisesti ensimmäisellä kerralla. Ctrl+C pysäyttää.</div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
/* Oletusvärit — ylikirjoitetaan teemalla */
:root { :root {
--bg: #0d1117; --bg: #0d1117;
--panel: #161b22; --panel: #161b22;
@@ -8,6 +9,53 @@
--red: #f85149; --red: #f85149;
--purple: #a371f7; --purple: #a371f7;
--border: #30363d; --border: #30363d;
--hero-accent: #ff6b00;
--hero-glow: rgba(255, 107, 0, 0.15);
}
/* Gecko — lämmin kulta/oranssi (kipina.tech) */
[data-theme="gecko"] {
--bg: #0a0500;
--panel: #1f1000;
--text: #fff5e6;
--accent: #ff7b00;
--green: #3fb950;
--yellow: #ffae00;
--red: #f85149;
--purple: #ff9d4d;
--border: rgba(255, 174, 0, 0.2);
--hero-accent: #ff7b00;
--hero-glow: rgba(255, 123, 0, 0.15);
}
/* Forge — kyber-sininen/syaani (kipina.tech) */
[data-theme="forge"] {
--bg: #060b11;
--panel: #121e2d;
--text: #e0f2fe;
--accent: #00e5ff;
--green: #3fb950;
--yellow: #ff5e3a;
--red: #f85149;
--purple: #7dd3fc;
--border: rgba(0, 229, 255, 0.15);
--hero-accent: #00e5ff;
--hero-glow: rgba(0, 229, 255, 0.15);
}
/* Serpent — neon-turkoosi/teal (kipina.tech) */
[data-theme="serpent"] {
--bg: #000808;
--panel: #001e1e;
--text: #ccffff;
--accent: #00ffff;
--green: #00ffaa;
--yellow: #d29922;
--red: #f85149;
--purple: #66cccc;
--border: rgba(0, 255, 255, 0.15);
--hero-accent: #00ffff;
--hero-glow: rgba(0, 255, 255, 0.15);
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -20,10 +68,24 @@ body {
min-height: 100vh; min-height: 100vh;
} }
.container { max-width: 1600px; margin: 0 auto; padding: 20px 40px; } .container {
max-width: 1600px;
margin: 0 auto;
padding: 20px 40px;
}
#app.container {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
#app:not(.active) { display: none; }
#landing.hidden { display: none; }
/* Tabs */ /* Tabs */
.tabs { display: flex; gap: 4px; margin-bottom: 16px; } .tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-shrink: 0; }
.tab { .tab {
padding: 10px 20px; border-radius: 6px 6px 0 0; cursor: pointer; padding: 10px 20px; border-radius: 6px 6px 0 0; cursor: pointer;
border: 1px solid var(--border); border-bottom: none; border: 1px solid var(--border); border-bottom: none;
@@ -33,7 +95,7 @@ body {
/* Panels */ /* Panels */
.panel { display: none; } .panel { display: none; }
.panel.active { display: block; } .panel.active { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: auto; }
/* Status bar */ /* Status bar */
.status-bar { .status-bar {
@@ -52,7 +114,7 @@ body {
.terminal { .terminal {
background: #010409; border: 1px solid var(--border); border-top: none; background: #010409; border: 1px solid var(--border); border-top: none;
font-family: 'Courier New', monospace; font-size: 16px; font-family: 'Courier New', monospace; font-size: 16px;
min-height: 400px; max-height: 70vh; overflow-y: auto; flex: 1; min-height: 0; max-height: none; overflow-y: auto;
padding: 12px 16px; padding: 12px 16px;
} }
.terminal-line { padding: 1px 0; white-space: pre-wrap; word-break: break-word; } .terminal-line { padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
@@ -81,6 +143,12 @@ body {
} }
.dd-item:hover, .dd-item.active { background: var(--border); color: var(--accent); } .dd-item:hover, .dd-item.active { background: var(--border); color: var(--accent); }
#editor-file-list .dd-item {
white-space: pre-wrap;
word-break: break-all;
line-height: 1.4;
}
/* Pipeline progress */ /* Pipeline progress */
.pipeline-bar { .pipeline-bar {
display: none; padding: 8px 14px; background: var(--bg); display: none; padding: 8px 14px; background: var(--bg);
@@ -102,6 +170,7 @@ body {
.project-tab { .project-tab {
padding: 4px 10px; cursor: pointer; border-radius: 4px 4px 0 0; padding: 4px 10px; cursor: pointer; border-radius: 4px 4px 0 0;
font-size: 12px; color: #8b949e; font-size: 12px; color: #8b949e;
white-space: nowrap; flex-shrink: 0;
} }
.project-tab.active { background: var(--panel); color: var(--accent); border: 1px solid var(--border); border-bottom: none; } .project-tab.active { background: var(--panel); color: var(--accent); border: 1px solid var(--border); border-bottom: none; }
@@ -165,6 +234,13 @@ body {
.agent-avatar.active img { .agent-avatar.active img {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 25px rgba(88,166,255,0.8); box-shadow: 0 0 25px rgba(88,166,255,0.8);
animation: agentBlink 1.5s infinite;
}
@keyframes agentBlink {
0% { opacity: 0.8; box-shadow: 0 0 15px rgba(88,166,255,0.5); }
50% { opacity: 1.0; box-shadow: 0 0 35px rgba(88,166,255,1.0); }
100% { opacity: 0.8; box-shadow: 0 0 15px rgba(88,166,255,0.5); }
} }
/* Settings */ /* Settings */
@@ -195,6 +271,218 @@ body {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
} }
/* ===== LANDING PAGE ===== */
#landing {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
min-height: 100vh;
position: relative;
overflow: hidden;
}
.bg-mesh {
position: fixed; inset: 0; z-index: -1;
background:
radial-gradient(ellipse 80% 60% at 20% 40%, var(--hero-glow) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 20%, rgba(88,166,255,0.06) 0%, transparent 70%),
var(--bg);
}
.landing-nav {
padding: 20px 40px;
display: flex; align-items: center; justify-content: space-between;
}
.landing-logo { text-decoration: none; font-size: 18px; font-weight: 700; }
.logo-accent { color: var(--hero-accent); }
.logo-sub { color: #8b949e; font-weight: 400; }
.theme-cycle-btn {
background: none; border: 1px solid var(--border); border-radius: 8px;
width: 38px; height: 38px; font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.2s, transform 0.15s;
}
.theme-cycle-btn:hover {
border-color: var(--hero-accent); transform: scale(1.1);
}
/* Hero */
.hero {
padding: 60px 40px 40px;
}
.hero-container {
max-width: 1200px; margin: 0 auto;
display: grid; grid-template-columns: 1fr 400px; gap: 60px; align-items: center;
}
.hero-title {
font-size: clamp(2rem, 4vw, 3rem); font-weight: 800;
line-height: 1.15; color: #e6edf3; margin-bottom: 16px;
}
.hero-divider {
width: 60px; height: 3px; background: var(--hero-accent);
border-radius: 2px; margin-bottom: 20px;
}
.hero-desc {
font-size: 1.05rem; color: #8b949e; line-height: 1.7; margin-bottom: 12px;
}
.hero-notice {
font-size: 0.9rem; color: #6e7681; line-height: 1.6;
border-left: 2px solid var(--border); padding-left: 12px; margin-bottom: 28px;
}
/* Hero input */
.hero-input-group {
display: flex; gap: 8px; margin-bottom: 20px;
}
.hero-input {
flex: 1; padding: 14px 18px; font-size: 16px;
font-family: 'JetBrains Mono', 'Courier New', monospace;
background: var(--panel); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
outline: none; transition: border-color 0.2s;
}
.hero-input:focus {
border-color: var(--hero-accent); box-shadow: 0 0 0 3px var(--hero-glow);
}
.hero-input::placeholder { color: #484f58; }
.hero-input.shake {
animation: shake 0.4s ease;
border-color: #f85149;
box-shadow: 0 0 0 3px rgba(248,81,73,0.2);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}
.hero-btn {
padding: 14px 28px; font-size: 16px; font-weight: 600;
font-family: 'Inter', sans-serif;
background: var(--hero-accent); color: #fff; border: none; border-radius: 8px;
cursor: pointer; transition: background 0.2s, transform 0.1s;
white-space: nowrap;
}
.hero-btn:hover { filter: brightness(0.85); transform: translateY(-1px); }
.hero-btn:active { transform: translateY(0); }
/* Example buttons */
.hero-examples { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.hero-examples-label { color: #6e7681; font-size: 14px; margin-right: 4px; }
.example-btn {
padding: 8px 16px; font-size: 13px; font-family: 'Inter', sans-serif;
background: transparent; color: var(--accent);
border: 1px solid var(--border); border-radius: 6px;
cursor: pointer; transition: all 0.2s;
}
.example-btn:hover {
border-color: var(--accent); background: rgba(88,166,255,0.08);
}
/* Hero orb */
.hero-orb-wrapper {
display: flex; justify-content: center; align-items: center;
}
.hero-orb {
width: 340px; height: 340px; border-radius: 50%;
background: radial-gradient(circle at 30% 30%, var(--hero-glow) 0%, transparent 70%);
display: flex; align-items: center; justify-content: center;
animation: orb-float 6s ease-in-out infinite;
}
.hero-orb-img {
width: 100%; height: 100%; object-fit: contain;
filter: drop-shadow(0 0 40px var(--hero-glow));
transition: opacity 0.2s ease;
}
@keyframes orb-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
/* How section */
.how-section {
padding: 60px 40px;
background: rgba(22,27,34,0.6);
border-top: 1px solid var(--border);
}
.how-container { max-width: 900px; margin: 0 auto; }
.how-title {
text-align: center; font-size: 1.5rem; font-weight: 700;
color: #e6edf3; margin-bottom: 40px;
}
.how-steps {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px;
}
.how-step {
text-align: center; padding: 24px;
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; transition: border-color 0.3s;
}
.how-step:hover { border-color: var(--hero-accent); }
.how-step-num {
width: 40px; height: 40px; line-height: 40px;
border-radius: 50%; background: var(--hero-glow);
color: var(--hero-accent); font-weight: 700; font-size: 18px;
margin: 0 auto 14px;
}
.how-step h3 { color: #e6edf3; font-size: 1rem; margin-bottom: 8px; }
.how-step p { color: #8b949e; font-size: 0.9rem; line-height: 1.5; }
/* Landing footer */
.landing-footer {
text-align: center; padding: 32px 40px;
color: #484f58; font-size: 13px;
border-top: 1px solid var(--border);
}
.landing-footer a { color: #8b949e; }
/* Responsive */
@media (max-width: 860px) {
.hero-container { grid-template-columns: 1fr; gap: 32px; }
.hero-orb-wrapper { order: -1; }
.hero-orb { width: 220px; height: 220px; }
.how-steps { grid-template-columns: 1fr; }
.hero-input-group { flex-direction: column; }
}
/* ===== OPPIMISPOLKU ===== */
.learn-step {
margin: 12px 0; border: 1px solid var(--border);
border-radius: 8px; background: var(--panel); overflow: hidden;
}
.learn-step-header {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; cursor: pointer;
transition: background 0.15s;
}
.learn-step-header:hover { background: rgba(88,166,255,0.04); }
.learn-step-num {
width: 28px; height: 28px; line-height: 28px; text-align: center;
border-radius: 50%; background: var(--hero-glow);
color: var(--hero-accent); font-weight: 700; font-size: 13px; flex-shrink: 0;
}
.learn-step-agent {
font-weight: 600; color: #e6edf3; font-size: 14px;
}
.learn-step-label {
color: #8b949e; font-size: 13px; margin-left: auto;
}
.learn-step-body {
display: none; padding: 0 16px 16px;
border-top: 1px solid var(--border);
}
.learn-step-body.open { display: block; }
.learn-section-title {
color: var(--accent); font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
margin: 14px 0 6px;
}
.learn-code {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 12px; line-height: 1.6;
background: #010409; border: 1px solid var(--border);
border-radius: 6px; padding: 12px; overflow-x: auto;
max-height: 300px; overflow-y: auto; white-space: pre-wrap;
}
/* Animations */ /* Animations */
@keyframes blink { 0%,100% { opacity:1 } 50% { opacity:0 } } @keyframes blink { 0%,100% { opacity:1 } 50% { opacity:0 } }
@keyframes spin { to { transform: rotate(360deg) } } @keyframes spin { to { transform: rotate(360deg) } }

34
network-poc/hub-local.log Normal file
View File

@@ -0,0 +1,34 @@
Compiling hub v0.3.1 (/Users/jaakko/code/kipina-codes/playground/agentic-studio/network-poc/hub)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.95s
Running `target/debug/hub`
2026-04-12T04:56:09.723604Z  INFO hub: Tietokanta alustettu
2026-04-12T04:56:09.725088Z  INFO hub: Kipinä Agent Hub v0.3.1 käynnistyy osoitteessa http://localhost:3000
2026-04-12T04:56:18.997935Z  INFO hub: Solmu 1 yhdistyi osoitteesta 127.0.0.1
2026-04-12T04:56:19.027478Z  INFO hub: Solmu 1 (natiivi) | 127.0.0.1 | Mac | Darwin 26.3.1 | 12 ydintä | 32768 MB RAM | varaus: 4 GB
2026-04-12T04:56:19.029931Z  INFO hub: GPU 0: Apple M2 Max | VRAM: 0/24576 MB | 0°C | 0%
2026-04-12T04:56:31.260470Z  INFO hub: Solmu 2 yhdistyi osoitteesta 127.0.0.1
2026-04-12T04:56:31.281759Z  INFO hub: Solmu 2 (selain) | 127.0.0.1 | MacIntel | 11 ydintä | ~8 GB RAM | GPU: ei GPU:ta | tehtävä: viewer | varaus: 0 GB
2026-04-12T04:56:31.283313Z  INFO hub: Reititettiin API-pyyntö solmulle 1 (Malli: qwen-coder)
━━━ Solmu 1 ━━━ qwen2.5-coder:7b-instruct-q4_K_M (Ollama) ━━━
Prompt: "ping"
Vastaus: Pong! How can I assist you today?
11 tokenia | 4502ms | 56.3 tok/s
2026-04-12T04:56:36.419646Z  INFO hub: Solmu 2 (127.0.0.1) poistui verkosta.
2026-04-12T04:56:36.433155Z  INFO hub: Solmu 3 yhdistyi osoitteesta 127.0.0.1
2026-04-12T04:56:36.445127Z  INFO hub: Solmu 3 (selain) | 127.0.0.1 | MacIntel | 11 ydintä | ~8 GB RAM | GPU: ei GPU:ta | tehtävä: viewer | varaus: 0 GB
2026-04-12T04:56:36.445818Z  INFO hub: Reititettiin API-pyyntö solmulle 1 (Malli: qwen-coder)
━━━ Solmu 1 ━━━ qwen2.5-coder:7b-instruct-q4_K_M (Ollama) ━━━
Prompt: "ping"
Vastaus: Pong! How can I assist you today? If you have any questions or need information on a specific topic, feel free to let me know.
31 tokenia | 679ms | 57.5 tok/s
2026-04-12T04:56:39.466711Z  INFO hub: Solmu 3 (127.0.0.1) poistui verkosta.
2026-04-12T04:56:43.881216Z  INFO hub: Solmu 4 yhdistyi osoitteesta 127.0.0.1
2026-04-12T04:56:43.894385Z  INFO hub: Solmu 4 (selain) | 127.0.0.1 | MacIntel | 3 ydintä | ~16 GB RAM | GPU: ei GPU:ta | tehtävä: viewer | varaus: 0 GB
2026-04-12T04:56:43.894960Z  INFO hub: Reititettiin API-pyyntö solmulle 1 (Malli: qwen-coder)
━━━ Solmu 1 ━━━ qwen2.5-coder:7b-instruct-q4_K_M (Ollama) ━━━
Prompt: "ping"
Vastaus: Pong! How can I assist you today?
11 tokenia | 333ms | 58.7 tok/s

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "hub" name = "hub"
version = "0.3.1" version = "0.3.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -11,7 +11,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
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"

View File

@@ -49,6 +49,13 @@ impl NodeDb {
INSERT INTO _schema_version VALUES (3); INSERT INTO _schema_version VALUES (3);
"); ");
} }
if version < 4 {
let _ = conn.execute_batch("
ALTER TABLE node_sessions ADD COLUMN is_paused BOOLEAN DEFAULT 0;
DELETE FROM _schema_version;
INSERT INTO _schema_version VALUES (4);
");
}
conn.execute_batch(" conn.execute_batch("
CREATE TABLE IF NOT EXISTS node_sessions ( CREATE TABLE IF NOT EXISTS node_sessions (
@@ -84,7 +91,10 @@ impl NodeDb {
has_webgpu BOOLEAN, has_webgpu BOOLEAN,
-- Tehtävätilastot -- Tehtävätilastot
tasks_completed INTEGER DEFAULT 0 tasks_completed INTEGER DEFAULT 0,
-- Ohjaustilat
is_paused BOOLEAN DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS pair_results ( CREATE TABLE IF NOT EXISTS pair_results (
@@ -183,6 +193,14 @@ impl NodeDb {
); );
} }
pub fn update_session_status(&self, node_id: u64, is_paused: bool) {
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
let _ = conn.execute(
"UPDATE node_sessions SET is_paused = ?1 WHERE node_id = ?2 AND disconnected_at IS NULL",
params![is_paused as i64, node_id as i64],
);
}
/// Sulkee saman IP:n viewer-sessiot kun aktiivinen node liittyy /// Sulkee saman IP:n viewer-sessiot kun aktiivinen node liittyy
pub fn close_viewers_by_ip(&self, ip: &str) { pub fn close_viewers_by_ip(&self, ip: &str) {
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
@@ -216,7 +234,7 @@ impl NodeDb {
"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, selected_task, has_webgpu, tasks_completed allocated_gb, selected_task, has_webgpu, tasks_completed, is_paused
FROM node_sessions ORDER BY id DESC LIMIT ?1" FROM node_sessions ORDER BY id DESC LIMIT ?1"
).unwrap(); ).unwrap();
@@ -244,6 +262,7 @@ impl NodeDb {
"selected_task": row.get::<_, Option<String>>(19)?, "selected_task": row.get::<_, Option<String>>(19)?,
"has_webgpu": row.get::<_, Option<bool>>(20)?, "has_webgpu": row.get::<_, Option<bool>>(20)?,
"tasks_completed": row.get::<_, i64>(21)?, "tasks_completed": row.get::<_, i64>(21)?,
"is_paused": row.get::<_, Option<bool>>(22)?.unwrap_or(false),
})) }))
}).unwrap().filter_map(|r| r.ok()).collect() }).unwrap().filter_map(|r| r.ok()).collect()
} }

View File

@@ -25,7 +25,7 @@ const ALLOWED_ORIGINS: &[&str] = &[
]; ];
// Sallitut viestityyypit clientilta // Sallitut viestityyypit clientilta
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "llm_error", "download_progress", "user_text", "single_tokenize_done"]; const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "llm_error", "download_progress", "user_text", "single_tokenize_done", "status_update"];
struct AppState { struct AppState {
next_node_id: Mutex<u64>, next_node_id: Mutex<u64>,
@@ -40,9 +40,14 @@ struct AppState {
node_ips: Mutex<HashMap<u64, IpAddr>>, node_ips: Mutex<HashMap<u64, IpAddr>>,
node_tasks: Mutex<HashMap<u64, String>>, // node_id → selected_task node_tasks: Mutex<HashMap<u64, String>>, // node_id → selected_task
node_types: Mutex<HashMap<u64, String>>, // node_id → "native" | "browser" node_types: Mutex<HashMap<u64, String>>, // node_id → "native" | "browser"
node_paused: Mutex<std::collections::HashSet<u64>>, // node_id → onko tauolla
node_busy: Mutex<std::collections::HashSet<u64>>, // Solmut joilla on aktiivinen tehtävä node_busy: Mutex<std::collections::HashSet<u64>>, // Solmut joilla on aktiivinen tehtävä
node_active_task: Mutex<HashMap<u64, String>>, // node_id → task_id (mikä tehtävä on kesken)
pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi) pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi)
pending_responses: Mutex<HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>>, // task_id → oneshot API-vastaukselle
api_rate_limits: Mutex<HashMap<IpAddr, (std::time::Instant, u32)>>, // IP → (ikkuna-alku, pyyntömäärä) api_rate_limits: Mutex<HashMap<IpAddr, (std::time::Instant, u32)>>, // IP → (ikkuna-alku, pyyntömäärä)
node_models: tokio::sync::RwLock<HashMap<u64, serde_json::Value>>, // node_id → ollama tags JSON
node_max_param_b: tokio::sync::RwLock<HashMap<u64, u32>>, // node_id → suurimman mallin parametrit (B)
db: db::NodeDb, db: db::NodeDb,
} }
@@ -80,6 +85,8 @@ tr:hover td { background:#1c2333; }
.table-wrap { overflow-x:auto; max-height:70vh; overflow-y:auto; } .table-wrap { overflow-x:auto; max-height:70vh; overflow-y:auto; }
.online { color:var(--green); } .online { color:var(--green); }
.offline { color:#8b949e; } .offline { color:#8b949e; }
.pause-btn { background:var(--panel); border:1px solid var(--border); color:var(--text); padding:4px 8px; border-radius:4px; cursor:pointer; font-size:12px; }
.pause-btn:hover { border-color:var(--yellow); }
</style> </style>
</head> </head>
<body> <body>
@@ -91,6 +98,7 @@ tr:hover td { background:#1c2333; }
<div class="tabs"> <div class="tabs">
<div class="tab active" onclick="showTab('sessions')">Sessiot</div> <div class="tab active" onclick="showTab('sessions')">Sessiot</div>
<div class="tab" onclick="showTab('pairs')">Tokenisointiparit</div> <div class="tab" onclick="showTab('pairs')">Tokenisointiparit</div>
<div class="tab" onclick="showTab('hardware')">Laitteisto & Mallit</div>
</div> </div>
<div id="sessions" class="panel active"> <div id="sessions" class="panel active">
@@ -99,12 +107,12 @@ tr:hover td { background:#1c2333; }
<colgroup> <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: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: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"> <col style="width:65px"><col style="width:40px"><col style="width:130px"><col style="width:60px"><col style="width:80px">
</colgroup> </colgroup>
<thead><tr> <thead><tr>
<th>ID</th><th>Tila</th><th>Tehtävä</th><th>Tyyppi</th><th>IP</th><th>Alusta</th> <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><th>Toiminnot</th>
</tr></thead><tbody id="sessions-body"></tbody></table> </tr></thead><tbody id="sessions-body"></tbody></table>
</div> </div>
</div> </div>
@@ -118,6 +126,19 @@ tr:hover td { background:#1c2333; }
</div> </div>
</div> </div>
<div id="hardware" class="panel">
<div class="stats-grid" id="hardware-stats"></div>
<h2 style="margin-top: 10px; margin-bottom: 10px; color: var(--accent); font-size: 16px;">Käytettävissä olevat paikalliset kielimallit</h2>
<div class="table-wrap">
<table>
<thead><tr>
<th>Nimi</th><th>Koko</th><th>Parametrit</th>
</tr></thead>
<tbody id="models-body"></tbody>
</table>
</div>
</div>
<script> <script>
function showTab(name) { function showTab(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
@@ -149,12 +170,16 @@ function duration(start, end) {
} }
async function load() { async function load() {
const [statsRes, sessionsRes, pairsRes] = await Promise.all([ const [statsRes, sessionsRes, pairsRes, hwRes, modelsRes] = await Promise.all([
fetch('/api/stats'), fetch('/api/sessions'), fetch('/api/pairs') fetch('/api/stats'), fetch('/api/sessions'), fetch('/api/pairs'),
fetch('/api/v1/hardware').catch(() => ({json: async()=>({gpu_name:'', vram_mb:0, ram_mb:0})})),
fetch('/api/v1/ollama/tags').catch(() => ({json: async()=>({models:[]})}))
]); ]);
const stats = await statsRes.json(); const stats = await statsRes.json();
const sessions = await sessionsRes.json(); const sessions = await sessionsRes.json();
const pairs = await pairsRes.json(); const pairs = await pairsRes.json();
const hw = await hwRes.json().catch(() => ({gpu_name:'', vram_mb:0, ram_mb:0}));
const modelsData = await modelsRes.json().catch(() => ({models:[]}));
// Versio // Versio
if (stats.version) document.getElementById('admin-version').textContent = 'v' + stats.version; if (stats.version) document.getElementById('admin-version').textContent = 'v' + stats.version;
@@ -173,7 +198,7 @@ async function load() {
].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 — lajittelu: 1) aktiiviset nodet (online + ei viewer), 2) katsojat (online + viewer), 3) offline // 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'}; const taskNames = {'tokenize':'Tokenisaatio','qwen-05b':'Qwen2.5 0.5B','qwen-coder-05b':'Coder 0.5B','qwen-coder-3b':'Coder 3B','viewer':'Katsoja','codelab-viewer':'Koodilabra'};
sessions.sort((a, b) => { sessions.sort((a, b) => {
const aOnline = !a.disconnected_at; const aOnline = !a.disconnected_at;
const bOnline = !b.disconnected_at; const bOnline = !b.disconnected_at;
@@ -190,9 +215,17 @@ async function load() {
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 isViewer = s.selected_task === 'viewer'; const isViewer = s.selected_task === 'viewer';
const status = online let status;
? (isViewer ? '<span style="color:#d29922">CONNECTED</span>' : '<span class="online">ACTIVE</span>') if (!online) {
: '<span class="offline">offline</span>'; status = '<span class="offline">offline</span>';
} else if (isViewer) {
status = '<span style="color:#d29922">CONNECTED</span>';
} else if (s.is_paused) {
status = '<span style="color:#8b949e">PAUSED</span>';
} else {
status = '<span class="online">ACTIVE</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 taskColor = isViewer ? 'yellow' : s.selected_task === 'tokenize' ? 'green' : 'blue';
const taskBadge = badge(taskNames[s.selected_task] || s.selected_task || '?', taskColor); const taskBadge = badge(taskNames[s.selected_task] || s.selected_task || '?', taskColor);
@@ -205,11 +238,16 @@ async function load() {
const os = s.os || '-'; const os = s.os || '-';
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);
const actionBtn = online && !isViewer
? `<button class="pause-btn" onclick="togglePause(${s.node_id}, ${s.is_paused})">${s.is_paused ? '▶ Työhön' : '⏸ Tauolle'}</button>`
: '';
return `<tr> return `<tr>
<td>${s.node_id}</td><td>${status}</td><td>${taskBadge}</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>
<td>${actionBtn}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -229,6 +267,35 @@ async function load() {
<td>${p.duration_ms||0}ms</td> <td>${p.duration_ms||0}ms</td>
</tr>`; </tr>`;
}).join(''); }).join('');
// Hardware
document.getElementById('hardware-stats').innerHTML = [
{v: hw.gpu_name || '-', l: 'Paikallinen GPU tila'},
{v: hw.vram_mb ? hw.vram_mb + ' MB' : '-', l: 'GPU Muisti (VRAM)'},
{v: hw.ram_mb ? hw.ram_mb + ' MB' : '-', l: 'RAM'},
].map(s => `<div class="stat-card"><div class="val">${s.v}</div><div class="label">${s.l}</div></div>`).join('');
// Models
document.getElementById('models-body').innerHTML = (modelsData.models || []).map(m => {
const sizeGb = (m.size / (1024*1024*1024)).toFixed(2) + ' GB';
const params = m.details?.parameter_size || '-';
return `<tr>
<td><strong>${m.name}</strong></td>
<td>${sizeGb}</td>
<td>${params}</td>
</tr>`;
}).join('');
}
async function togglePause(nodeId, isPaused) {
try {
await fetch('/api/v1/control/' + nodeId, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: isPaused ? 'resume' : 'pause' })
});
load(); // virkistetään
} catch(e) { console.error(e); }
} }
load(); load();
@@ -262,9 +329,14 @@ async fn main() {
node_ips: Mutex::new(HashMap::new()), node_ips: Mutex::new(HashMap::new()),
node_tasks: Mutex::new(HashMap::new()), node_tasks: Mutex::new(HashMap::new()),
node_types: Mutex::new(HashMap::new()), node_types: Mutex::new(HashMap::new()),
node_paused: Mutex::new(std::collections::HashSet::new()),
node_busy: Mutex::new(std::collections::HashSet::new()), node_busy: Mutex::new(std::collections::HashSet::new()),
node_active_task: Mutex::new(HashMap::new()),
pending_task_ids: Mutex::new(std::collections::HashSet::new()), pending_task_ids: Mutex::new(std::collections::HashSet::new()),
pending_responses: Mutex::new(HashMap::new()),
api_rate_limits: Mutex::new(HashMap::new()), api_rate_limits: Mutex::new(HashMap::new()),
node_models: tokio::sync::RwLock::new(HashMap::new()),
node_max_param_b: tokio::sync::RwLock::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())),
}); });
@@ -351,9 +423,7 @@ async fn main() {
// Vapaa node -> lähetetään oikea tehtävä // Vapaa node -> lähetetään oikea tehtävä
let msg = match task.as_str() { let msg = match task.as_str() {
"tokenize" => Some(serde_json::json!({ "type": "pair_task", "en": en, "fi": fi })), "tokenize" => Some(serde_json::json!({ "type": "pair_task", "en": en, "fi": fi })),
"smollm-135m" => Some(serde_json::json!({ "type": "llm_prompt", "prompt": llm_prompts[llm_idx], "model": "smollm-135m" })),
"qwen-05b" => Some(serde_json::json!({ "type": "llm_prompt", "prompt": llm_prompts[llm_idx], "model": "qwen-05b" })), "qwen-05b" => Some(serde_json::json!({ "type": "llm_prompt", "prompt": llm_prompts[llm_idx], "model": "qwen-05b" })),
"phi3-mini" => Some(serde_json::json!({ "type": "llm_prompt", "prompt": llm_prompts[llm_idx], "model": "phi3-mini" })),
_ => None, // Coder ja viewer ei saa auto-tehtäviä _ => None, // Coder ja viewer ei saa auto-tehtäviä
}; };
@@ -381,6 +451,7 @@ async fn main() {
.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("/api/v1/chat/completions", axum::routing::post(api_chat_completions))
.route("/api/v1/control/:id", axum::routing::post(api_control_node))
.route("/api/v1/model", axum::routing::post(api_change_model)) .route("/api/v1/model", axum::routing::post(api_change_model))
.route("/api/v1/hardware", get(api_hardware)) .route("/api/v1/hardware", get(api_hardware))
.route("/api/v1/ollama/tags", get(api_ollama_tags)) .route("/api/v1/ollama/tags", get(api_ollama_tags))
@@ -400,6 +471,26 @@ async fn main() {
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap(); axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
} }
async fn api_control_node(
headers: axum::http::HeaderMap,
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<u64>,
axum::Json(payload): axum::Json<serde_json::Value>,
) -> axum::response::Response {
if !check_admin_auth(&headers) { return admin_unauthorized(); }
let action = payload.get("action").and_then(|v| v.as_str()).unwrap_or("");
if action == "pause" || action == "resume" {
let msg = serde_json::json!({ "type": "control", "action": action });
let channels = state.node_channels.read().await;
if let Some(tx) = channels.get(&id) {
let _ = tx.send(msg.to_string());
tracing::info!("Lähetetty control: {} solmulle {}", action, id);
return axum::Json(serde_json::json!({"status": "ok"})).into_response();
}
}
(axum::http::StatusCode::BAD_REQUEST, "Invalid action or node offline").into_response()
}
async fn api_sessions( async fn api_sessions(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
axum::extract::State(state): axum::extract::State<Arc<AppState>>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
@@ -563,6 +654,17 @@ async fn broadcast_stats(state: &Arc<AppState>) {
"tasks": completed "tasks": completed
}); });
let _ = state.stats_tx.send(stats_msg.to_string()); let _ = state.stats_tx.send(stats_msg.to_string());
// Uutta: Laitetaan sama tieto myös kaikille yhdistyneille solmuille (viesti Hubilta Solmuille)
let node_status = serde_json::json!({
"type": "network_status",
"active_nodes": total_nodes,
"tasks": completed
});
let msg_str = node_status.to_string();
for tx in state.node_channels.read().await.values() {
let _ = tx.send(msg_str.clone());
}
} }
/// Validoi client-viesti: pakollinen "type"-kenttä, sallittu tyyppi, validi JSON /// Validoi client-viesti: pakollinen "type"-kenttä, sallittu tyyppi, validi JSON
@@ -730,6 +832,9 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
} }
state.node_tasks.lock().unwrap().insert(node_id, selected_task); state.node_tasks.lock().unwrap().insert(node_id, selected_task);
state.node_types.lock().unwrap().insert(node_id, node_type.to_string()); state.node_types.lock().unwrap().insert(node_id, node_type.to_string());
// Uudelleen-kirjautuessa nollataan tauko
state.node_paused.lock().unwrap().remove(&node_id);
state.db.update_session_status(node_id, false);
if node_type == "native" { if node_type == "native" {
let sys = json.get("system"); let sys = json.get("system");
@@ -743,6 +848,36 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
node_id, ip, hostname, os, cores, ram, allocated node_id, ip, hostname, os, cores, ram, allocated
); );
// Tallennetaan välitetyt mallit muistiin + parsitaan suurin malli
if let Some(models) = json.get("models") {
let mut nm = state.node_models.write().await;
nm.insert(node_id, models.clone());
// Parsitaan suurin mallikoko (B) nimestä: "qwen3:32b" → 32, "qwen2.5-coder:7b" → 7
let max_b = models.get("models").and_then(|v| v.as_array()).map(|arr| {
arr.iter().filter_map(|m| {
let name = m.get("name")?.as_str()?;
// Etsitään :N tai :Nb tai -Nb muoto
let lower = name.to_lowercase();
for part in lower.split(&[':', '-'][..]) {
if let Some(num_str) = part.strip_suffix('b') {
if let Ok(n) = num_str.parse::<f32>() { return Some(n as u32); }
} else if let Ok(n) = part.parse::<f32>() {
if n >= 0.5 && n <= 500.0 { return Some(n as u32); }
}
}
// Fallback: koko tiedostosta (size / ~0.5GB per B param Q4)
let size = m.get("size")?.as_u64()?;
Some((size / 500_000_000) as u32) // karkea arvio
}).max().unwrap_or(0)
}).unwrap_or(0);
if max_b > 0 {
state.node_max_param_b.write().await.insert(node_id, max_b);
tracing::info!("Solmu {} — suurin malli: ~{}B parametria", node_id, max_b);
}
}
if let Some(gpus) = json.get("gpus").and_then(|v| v.as_array()) { if let Some(gpus) = json.get("gpus").and_then(|v| v.as_array()) {
for gpu in gpus { for gpu in gpus {
tracing::info!( tracing::info!(
@@ -780,6 +915,18 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
}); });
let _ = state.stats_tx.send(join_msg.to_string()); let _ = state.stats_tx.send(join_msg.to_string());
} else if msg_type == "status_update" {
let status = json.get("status").and_then(|v| v.as_str()).unwrap_or("active");
if status == "paused" {
state.node_paused.lock().unwrap().insert(node_id);
state.db.update_session_status(node_id, true);
tracing::info!("Solmu {} ({}) asettui tauolle.", node_id, ip);
} else {
state.node_paused.lock().unwrap().remove(&node_id);
state.db.update_session_status(node_id, false);
tracing::info!("Solmu {} ({}) on taas aktiivinen.", node_id, ip);
}
broadcast_stats(&state).await;
} else if msg_type == "result" { } else if msg_type == "result" {
tracing::info!("Solmu {} sai tuloksen: {}", node_id, text); tracing::info!("Solmu {} sai tuloksen: {}", node_id, text);
{ {
@@ -789,6 +936,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
broadcast_stats(&state).await; broadcast_stats(&state).await;
} else if msg_type == "pair_done" { } else if msg_type == "pair_done" {
state.node_busy.lock().unwrap().remove(&node_id); state.node_busy.lock().unwrap().remove(&node_id);
state.node_active_task.lock().unwrap().remove(&node_id);
{ {
let mut json = json; // Siirretään omistajuus muokkausta varten let mut json = json; // Siirretään omistajuus muokkausta varten
if let Some(obj) = json.as_object_mut() { if let Some(obj) = json.as_object_mut() {
@@ -875,30 +1023,44 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
} else if msg_type == "llm_done" { } else if msg_type == "llm_done" {
// Vapautetaan solmu ja tarkistetaan task_id:n aitous // Vapautetaan solmu ja tarkistetaan task_id:n aitous
state.node_busy.lock().unwrap().remove(&node_id); 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.node_active_task.lock().unwrap().remove(&node_id);
state.pending_task_ids.lock().unwrap().remove(tid) let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
let valid_task = if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str())
} else { } else {
false false
}; };
// Jos API-pyyntö odottaa tätä vastausta, reititetään suoraan oneshot-kanavaan
let api_sender = task_id.as_ref().and_then(|tid| {
state.pending_responses.lock().unwrap().remove(tid)
});
{ {
let mut json = json; let mut json = json;
if let Some(obj) = json.as_object_mut() { if let Some(obj) = json.as_object_mut() {
let model = obj.get("model").and_then(|v| v.as_str()).unwrap_or("?"); 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 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 _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 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 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); let tok_s = obj.get("tokens_per_sec").and_then(|v| v.as_f64()).unwrap_or(0.0);
println!(); println!();
println!("\x1b[35m━━━ Solmu {} ━━━ {} ━━━\x1b[0m", node_id, model); println!("\x1b[35m━━━ Solmu {} ━━━ {} ━━━\x1b[0m", node_id, model);
println!(" Prompt: \x1b[33m\"{}\"\x1b[0m", prompt); let prompt_preview: String = prompt.chars().take(80).collect();
println!(" Vastaus: \x1b[32m{}\x1b[0m", response); println!(" Prompt: \x1b[33m\"{}...\"\x1b[0m", prompt_preview);
println!(" {} tokenia | {:.0}ms | \x1b[36m{:.1} tok/s\x1b[0m", tok_gen, duration, tok_s); println!(" {} tokenia | {:.0}ms | \x1b[36m{:.1} tok/s\x1b[0m", tok_gen, duration, tok_s);
state.db.increment_tasks(node_id); state.db.increment_tasks(node_id);
obj.insert("node_id".to_string(), serde_json::json!(node_id)); obj.insert("node_id".to_string(), serde_json::json!(node_id));
} }
if let Some(sender) = api_sender {
// API-pyyntö: reititetään vastaus suoraan odottajalle
let _ = sender.send(json.clone());
}
// UI-broadcast jatkuu normaalisti
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 active_incentives = state.feature_flags.read().await.get("Insentiivit").copied().unwrap_or(false);
@@ -908,7 +1070,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
{ {
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 && valid_task { if active_incentives && valid_task {
let mut tokens = state.nodes_tokens.lock().unwrap(); let mut tokens = state.nodes_tokens.lock().unwrap();
let balance = tokens.entry(node_id).or_insert(0); let balance = tokens.entry(node_id).or_insert(0);
@@ -916,7 +1078,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
current_balance = *balance; current_balance = *balance;
} }
} }
if active_incentives && ui_sync { if active_incentives && ui_sync {
if let Some(tx) = state.node_channels.read().await.get(&node_id) { if let Some(tx) = state.node_channels.read().await.get(&node_id) {
let msg = serde_json::json!({ let msg = serde_json::json!({
@@ -926,45 +1088,51 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
let _ = tx.send(msg.to_string()); let _ = tx.send(msg.to_string());
} }
} }
broadcast_stats(&state).await; broadcast_stats(&state).await;
} }
} else if msg_type == "llm_error" { } else if msg_type == "llm_error" {
state.node_busy.lock().unwrap().remove(&node_id); state.node_busy.lock().unwrap().remove(&node_id);
if let Some(tid) = json.get("task_id").and_then(|v| v.as_str()) { state.node_active_task.lock().unwrap().remove(&node_id);
state.pending_task_ids.lock().unwrap().remove(tid); let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str());
} }
// Jos API-pyyntö odottaa, reititetään virhe oneshot-kanavaan
let api_sender = task_id.as_ref().and_then(|tid| {
state.pending_responses.lock().unwrap().remove(tid)
});
{ {
let mut json = json; let mut json = json;
if let Some(obj) = json.as_object_mut() { if let Some(obj) = json.as_object_mut() {
obj.insert("node_id".to_string(), serde_json::json!(node_id)); obj.insert("node_id".to_string(), serde_json::json!(node_id));
} }
if let Some(sender) = api_sender {
let _ = sender.send(json.clone());
}
let _ = state.stats_tx.send(json.to_string()); let _ = state.stats_tx.send(json.to_string());
} }
} else if msg_type == "user_text" { } else if msg_type == "user_text" {
// Käyttäjän lähettämä teksti — broadcastataan pair_taskina ja llm_promptina // Käyttäjän lähettämä teksti — kohdennettu reititys lähettäjäsolmulle
let text = json.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); 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"); let task_type = json.get("task_type").and_then(|v| v.as_str()).unwrap_or("tokenize");
if !text.is_empty() { if !text.is_empty() {
let preview: String = text.chars().take(80).collect(); let preview: String = text.chars().take(80).collect();
tracing::info!("Solmu {} lähetti oman tekstin ({}): \"{}\"", node_id, task_type, preview); tracing::info!("Solmu {} lähetti oman tekstin ({}): \"{}\"", node_id, task_type, preview);
match task_type { let msg = match task_type {
"tokenize" => { "tokenize" => serde_json::json!({
let msg = serde_json::json!({ "type": "single_tokenize",
"type": "single_tokenize", "text": text,
"text": text, }),
}); _ => serde_json::json!({
let _ = state.stats_tx.send(msg.to_string()); "type": "llm_prompt",
} "prompt": text,
_ => { "model": task_type,
// 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", // Lähetetään takaisin lähettäjäsolmulle (käyttäjä haluaa oman tekstinsä tuloksen)
"prompt": text, if let Some(tx) = state.node_channels.read().await.get(&node_id) {
"model": task_type, let _ = tx.send(msg.to_string());
});
let _ = state.stats_tx.send(prompt.to_string());
}
} }
} }
} }
@@ -972,6 +1140,22 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
// Yhteys katkesi — merkitään session päättyneeksi ja siivotaan atomisesti // Yhteys katkesi — merkitään session päättyneeksi ja siivotaan atomisesti
state.db.close_session(node_id); state.db.close_session(node_id);
// Jos solmulla oli kesken tehtävä, ilmoitetaan odottavalle API-kutsulle
let lost_task_id = state.node_active_task.lock().unwrap().remove(&node_id);
if let Some(tid) = lost_task_id {
tracing::warn!("Solmu {} katosi kesken tehtävän {} — palautetaan virhe API:lle", node_id, tid);
state.pending_task_ids.lock().unwrap().remove(&tid);
if let Some(resp_tx) = state.pending_responses.lock().unwrap().remove(&tid) {
let err = serde_json::json!({
"type": "llm_error",
"error": format!("Solmu #{} katosi kesken laskennan (task {})", node_id, tid),
"task_id": tid
});
let _ = resp_tx.send(err);
}
}
{ {
// Lukitaan kaikki kerralla, jotta solmu ei ole osittain siivottu // Lukitaan kaikki kerralla, jotta solmu ei ole osittain siivottu
let mut tasks = state.node_tasks.lock().unwrap(); let mut tasks = state.node_tasks.lock().unwrap();
@@ -989,6 +1173,9 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
vram.remove(&node_id); vram.remove(&node_id);
} }
state.node_types.lock().unwrap().remove(&node_id); state.node_types.lock().unwrap().remove(&node_id);
state.node_paused.lock().unwrap().remove(&node_id);
state.node_models.write().await.remove(&node_id);
state.node_max_param_b.write().await.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();
@@ -1000,6 +1187,18 @@ struct ChatCompletionRequest {
task_id: String, task_id: String,
#[serde(default)] #[serde(default)]
max_tokens: Option<u64>, max_tokens: Option<u64>,
#[serde(default)]
system_prompt: Option<String>,
#[serde(default)]
temperature: Option<f64>,
#[serde(default)]
top_k: Option<u64>,
#[serde(default)]
repeat_penalty: Option<f64>,
#[serde(default)]
stop: Option<Vec<String>>,
#[serde(default)]
capability: Option<String>, // "heavy" → priorisoi isoin malli, "light" → mikä tahansa
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -1009,7 +1208,16 @@ struct ChatCompletionResponse {
tokens_generated: u64, tokens_generated: u64,
} }
async fn api_ollama_tags() -> axum::response::Response { async fn api_ollama_tags(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
) -> axum::response::Response {
// Haetaan natiivisolmun tila muistista — priorisoidaan aito verkko-solmu
let node_models = state.node_models.read().await;
if let Some((_, models_json)) = node_models.iter().next() {
return axum::Json(models_json.clone()).into_response();
}
// Fallback: Haetaan lokaalista infra-Ollamasta ohjaimesta käsin (esim dev ympäristö)
let ollama_url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); let ollama_url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
match reqwest::get(format!("{}/api/tags", ollama_url)).await { match reqwest::get(format!("{}/api/tags", ollama_url)).await {
Ok(resp) => { Ok(resp) => {
@@ -1033,11 +1241,10 @@ async fn api_hardware(
}); });
let (mut vram_mb, mut gpu_name, ram_mb) = if let Some(s) = native { let (mut vram_mb, mut gpu_name, ram_mb) = if let Some(s) = native {
let gpus = s.get("gpus").and_then(|v| v.as_array()); // Tieto on tietokannassa litteänä
let gpu = gpus.and_then(|g| g.first()); let vram = s.get("vram_total_mb").and_then(|v| v.as_u64()).unwrap_or(0);
let vram = gpu.and_then(|g| g.get("vram_total_mb")).and_then(|v| v.as_u64()).unwrap_or(0); let name = s.get("gpu_name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let name = gpu.and_then(|g| g.get("name")).and_then(|v| v.as_str()).unwrap_or("").to_string(); let ram = s.get("ram_mb").and_then(|v| v.as_u64()).unwrap_or(0);
let ram = s.get("system").and_then(|v| v.get("ram_total_mb")).and_then(|v| v.as_u64()).unwrap_or(0);
(vram, name, ram) (vram, name, ram)
} else { } else {
(0, String::new(), 0) (0, String::new(), 0)
@@ -1101,13 +1308,26 @@ async fn api_chat_completions(
} }
} }
// Etsitään vapaa solmu — priorisoidaan natiivisolmut (GPU) selaimen edelle // Etsitään vapaa solmu — älykäs reititys kyvykkyyden mukaan
let want_heavy = payload.capability.as_deref() == Some("heavy");
// Haetaan param_b-snapshot ennen Mutex-lukituksia (async RwLock ei saa olla Mutex-scopen sisällä)
let param_b_snapshot: HashMap<u64, u32> = state.node_max_param_b.read().await.clone();
let (target_node, _total_matching) = { let (target_node, _total_matching) = {
let tasks = state.node_tasks.lock().unwrap(); let tasks = state.node_tasks.lock().unwrap();
let _busy = state.node_busy.lock().unwrap(); let busy = state.node_busy.lock().unwrap();
let node_types = state.node_types.lock().unwrap(); let node_types = state.node_types.lock().unwrap();
let matching: Vec<u64> = tasks.iter().filter(|(_, task)| { let paused = state.node_paused.lock().unwrap();
// Eksakti match tai qwen-perheen yhteensopivuus (selain: qwen-coder-05b, natiivi: qwen2.5-coder:7b) // Debug: logita kaikki solmut ja niiden tilat
let all_nodes: Vec<String> = tasks.iter().map(|(id, task)| {
let ty = node_types.get(id).map(|s| s.as_str()).unwrap_or("?");
let b = if busy.contains(id) { " BUSY" } else { "" };
let p = if paused.contains(id) { " PAUSED" } else { "" };
format!("#{}({}:{}{}{}", id, ty, task, b, p)
}).collect();
tracing::info!("Reititys '{}'{} — solmut: [{}]", payload.model, if want_heavy { " (heavy)" } else { "" }, all_nodes.join(", "));
let matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
if paused.contains(k) { return false; } // Ei tauotettuja
if busy.contains(k) { return false; } // Ei varattuja
let req_model = payload.model.to_lowercase(); let req_model = payload.model.to_lowercase();
let node_task = task.to_lowercase(); let node_task = task.to_lowercase();
if req_model.starts_with("qwen") { if req_model.starts_with("qwen") {
@@ -1118,11 +1338,32 @@ async fn api_chat_completions(
**task == payload.model **task == payload.model
} }
}).map(|(k, _)| *k).collect(); }).map(|(k, _)| *k).collect();
// Etsitään mikä tahansa matchaava solmu (natiivi priorisoidaan)
let native = matching.iter().find(|id| { let any = if want_heavy {
node_types.get(id).map(|t| t == "native").unwrap_or(false) // Heavy: priorisoi solmu jolla on suurin malli (B-parametrit)
}).copied(); let mut ranked: Vec<(u64, u32)> = matching.iter().map(|id| {
let any = native.or_else(|| matching.first().copied()); (*id, param_b_snapshot.get(id).copied().unwrap_or(0))
}).collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1)); // suurin ensin
if let Some((best_id, best_b)) = ranked.first() {
tracing::info!("Heavy-reititys: solmu {} valittu ({}B parametria)", best_id, best_b);
Some(*best_id)
} else {
// Kaikki heavy-solmut busy — fallback mihin tahansa vapaaseen
let all_matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
if paused.contains(k) || busy.contains(k) { return false; }
let req_model = payload.model.to_lowercase();
task.to_lowercase().starts_with(&req_model.split('-').next().unwrap_or(""))
}).map(|(k, _)| *k).collect();
all_matching.first().copied()
}
} else {
// Oletus: vapaa natiivi ensin, sitten mikä tahansa vapaa
let native = matching.iter().find(|id| {
node_types.get(id).map(|t| t == "native").unwrap_or(false)
}).copied();
native.or_else(|| matching.first().copied())
};
(any, matching.len()) (any, matching.len())
}; };
@@ -1149,6 +1390,7 @@ async fn api_chat_completions(
// Merkitään solmu varatuksi ja task_id jaetuksi // Merkitään solmu varatuksi ja task_id jaetuksi
state.node_busy.lock().unwrap().insert(target_node_id); state.node_busy.lock().unwrap().insert(target_node_id);
state.node_active_task.lock().unwrap().insert(target_node_id, payload.task_id.clone());
state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone()); state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone());
let mut msg = serde_json::json!({ let mut msg = serde_json::json!({
@@ -1157,12 +1399,17 @@ async fn api_chat_completions(
"model": payload.model, "model": payload.model,
"task_id": payload.task_id, "task_id": payload.task_id,
}); });
if let Some(mt) = payload.max_tokens { let obj = msg.as_object_mut().unwrap();
msg.as_object_mut().unwrap().insert("max_tokens".to_string(), serde_json::json!(mt)); if let Some(mt) = payload.max_tokens { obj.insert("max_tokens".to_string(), serde_json::json!(mt)); }
} if let Some(ref sp) = payload.system_prompt { obj.insert("system_prompt".to_string(), serde_json::json!(sp)); }
if let Some(t) = payload.temperature { obj.insert("temperature".to_string(), serde_json::json!(t)); }
if let Some(k) = payload.top_k { obj.insert("top_k".to_string(), serde_json::json!(k)); }
if let Some(rp) = payload.repeat_penalty { obj.insert("repeat_penalty".to_string(), serde_json::json!(rp)); }
if let Some(ref s) = payload.stop { obj.insert("stop".to_string(), serde_json::json!(s)); }
// Odotuskanava valmiiksi (solmu palauttaa tuloksen stats_tx kautta) // Oneshot-kanava: solmu palauttaa tuloksen suoraan tälle pyynnölle
let mut rx = state.stats_tx.subscribe(); let (resp_tx, resp_rx) = tokio::sync::oneshot::channel::<serde_json::Value>();
state.pending_responses.lock().unwrap().insert(payload.task_id.clone(), resp_tx);
// Kohdennettu reititys: lähetetään AI-tehtävä suoraan VAIN valitulle solmulle // Kohdennettu reititys: lähetetään AI-tehtävä suoraan VAIN valitulle solmulle
{ {
@@ -1171,48 +1418,39 @@ async fn api_chat_completions(
let _ = tx.send(msg.to_string()); let _ = tx.send(msg.to_string());
tracing::info!("Reititettiin API-pyyntö solmulle {} (Malli: {})", target_node_id, payload.model); tracing::info!("Reititettiin API-pyyntö solmulle {} (Malli: {})", target_node_id, payload.model);
} else { } else {
state.pending_responses.lock().unwrap().remove(&payload.task_id);
return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Verkkovirhe: solmun yhteys katkesi reitityksen aikana").into_response(); return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Verkkovirhe: solmun yhteys katkesi reitityksen aikana").into_response();
} }
} }
let timeout = tokio::time::timeout(std::time::Duration::from_secs(600), async move { let timeout = tokio::time::timeout(std::time::Duration::from_secs(120), resp_rx).await;
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 { match timeout {
Ok(Ok(Some(res))) => axum::Json(res).into_response(), Ok(Ok(v)) => {
Ok(Ok(None)) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response(), if v["type"].as_str() == Some("llm_error") {
Ok(Err(err)) => (axum::http::StatusCode::CONFLICT, err).into_response(), let err = v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string();
Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response(), (axum::http::StatusCode::CONFLICT, err).into_response()
} else {
axum::Json(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),
}).into_response()
}
}
Ok(Err(_)) => {
// Oneshot-kanava sulkeutui (solmu katosi kesken laskennan)
state.pending_responses.lock().unwrap().remove(&payload.task_id);
state.node_busy.lock().unwrap().remove(&target_node_id);
state.node_active_task.lock().unwrap().remove(&target_node_id);
(axum::http::StatusCode::SERVICE_UNAVAILABLE, "Solmu katosi kesken laskennan — yritä uudelleen").into_response()
}
Err(_) => {
// Timeout — solmu ei vastannut ajoissa
state.pending_responses.lock().unwrap().remove(&payload.task_id);
state.node_busy.lock().unwrap().remove(&target_node_id);
state.node_active_task.lock().unwrap().remove(&target_node_id);
(axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response()
}
} }
} }

View File

@@ -1,59 +0,0 @@
#!/bin/bash
# Kipinä Agentic Studio — asennusskripti (Debian/Ubuntu)
set -e
echo "=== Kipinä Agentic Studio — Asennus ==="
echo ""
# Tarkistetaan käyttöjärjestelmä
if [ ! -f /etc/debian_version ]; then
echo "⚠ Tämä skripti on suunniteltu Debian/Ubuntu-järjestelmille."
echo " Muilla jakeluilla voit asentaa riippuvuudet manuaalisesti."
read -p " Jatketaanko? (k/e) " -n 1 -r; echo
[[ $REPLY =~ ^[Kk]$ ]] || exit 1
fi
echo "[1/6] Päivitetään pakettilistaus..."
sudo apt-get update -qq
echo "[2/6] Asennetaan peruspaketteja..."
sudo apt-get install -y -qq curl git build-essential pkg-config libssl-dev
# Rust
if command -v rustc &>/dev/null; then
echo "[3/6] Rust löytyi: $(rustc --version)"
else
echo "[3/6] Asennetaan Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
fi
# Node.js (Astro-frontend vaatii)
if command -v node &>/dev/null; then
echo "[4/6] Node.js löytyi: $(node --version)"
else
echo "[4/6] Asennetaan Node.js 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y -qq nodejs
fi
# Ollama
if command -v ollama &>/dev/null; then
echo "[5/6] Ollama löytyi"
else
echo "[5/6] Asennetaan Ollama..."
curl -fsSL https://ollama.ai/install.sh | sh
fi
# Malli
echo "[6/6] Ladataan kielimalli (qwen2.5-coder:3b)..."
ollama pull qwen2.5-coder:3b
echo ""
echo "=== Asennus valmis! ==="
echo ""
echo "Käynnistä:"
echo " cd $(pwd)"
echo " ./network-poc/local.sh"
echo ""
echo "Avaa selaimessa: http://localhost:3000"

135
network-poc/kipina-node Normal file
View File

@@ -0,0 +1,135 @@
#!/bin/bash
# Kipinä Node — lataa oikea binääri ja käynnistä
set -e
BASE_URL="https://kipina.studio/download"
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
# Tunnista OS ja arkkitehtuuri
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS-$ARCH" in
darwin-arm64) BINARY="kipina-node-macos-arm64" ;;
darwin-x86_64) BINARY="kipina-node-macos-arm64" ;; # Rosetta
linux-x86_64) BINARY="kipina-node-linux-x86_64" ;;
linux-aarch64) BINARY="kipina-node-linux-arm64" ;;
*) echo "Ei tuettu: $OS-$ARCH"; exit 1 ;;
esac
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ Kipinä Agentic Node ║"
echo " ╚══════════════════════════════════════╝"
echo ""
echo " OS: $OS ($ARCH)"
echo ""
# Etsi Ollama-instanssit
CANDIDATES=(
"http://localhost:11434"
"http://127.0.0.1:11434"
"http://ollama:11434"
"http://host.docker.internal:11434"
)
# Lisää OLLAMA_URL listaan jos asetettu ja ei jo mukana
if [ -n "$OLLAMA_URL" ]; then
ALREADY=false
for c in "${CANDIDATES[@]}"; do
[ "$c" = "$OLLAMA_URL" ] && ALREADY=true
done
$ALREADY || CANDIDATES=("$OLLAMA_URL" "${CANDIDATES[@]}")
fi
echo " Etsitään Ollama-instansseja..."
FOUND=()
for url in "${CANDIDATES[@]}"; do
if curl -s --connect-timeout 1 "$url/api/tags" &>/dev/null; then
FOUND+=("$url")
fi
done
if [ ${#FOUND[@]} -eq 0 ]; then
# Ei löytynyt — yritä käynnistää lokaali
if command -v ollama &>/dev/null; then
echo " Käynnistetään Ollama..."
ollama serve &>/dev/null &
sleep 3
if curl -s --connect-timeout 1 "http://localhost:11434/api/tags" &>/dev/null; then
OLLAMA_URL="http://localhost:11434"
echo " ✓ Ollama käynnistetty ($OLLAMA_URL)"
else
echo " ✗ Ollaman käynnistys epäonnistui."
exit 1
fi
else
echo ""
echo " ✗ Ollamaa ei löytynyt."
echo " Kontti/remote: OLLAMA_URL=http://HOST:11434 ./kipina-node"
echo " Asenna: curl -fsSL https://ollama.ai/install.sh | sh"
exit 1
fi
elif [ ${#FOUND[@]} -eq 1 ]; then
OLLAMA_URL="${FOUND[0]}"
echo " ✓ Ollama löytyi: $OLLAMA_URL"
else
echo ""
echo " Löytyi ${#FOUND[@]} Ollama-instanssia:"
echo ""
for i in "${!FOUND[@]}"; do
echo " $((i+1))) ${FOUND[$i]}"
done
echo ""
read -p " Valitse [1-${#FOUND[@]}]: " -r CHOICE
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le ${#FOUND[@]} ]; then
OLLAMA_URL="${FOUND[$((CHOICE-1))]}"
else
OLLAMA_URL="${FOUND[0]}"
echo " Käytetään oletusta: $OLLAMA_URL"
fi
echo " ✓ Valittu: $OLLAMA_URL"
fi
echo ""
echo " Hub: $HUB_URL"
echo " Ollama: $OLLAMA_URL"
if [ -n "$KIPINA_MODEL" ]; then
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
fi
# Binäärin automaattinen päivitys — vertaa build-hashia palvelimeen
BIN_PATH="./kipina-node-bin"
HASH_PATH="./kipina-node-bin.hash"
REMOTE_HASH=$(curl -sSL "$BASE_URL/.build-hash?v=$(date +%s)" 2>/dev/null | tr -d '[:space:]')
LOCAL_HASH=""
[ -f "$HASH_PATH" ] && LOCAL_HASH=$(cat "$HASH_PATH" | tr -d '[:space:]')
if [ -f "$BIN_PATH" ] && [ -n "$REMOTE_HASH" ] && [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
echo " ✓ Binääri ajan tasalla (versio: $LOCAL_HASH)"
else
if [ -f "$BIN_PATH" ]; then
echo " ↻ Uusi versio saatavilla ($LOCAL_HASH → $REMOTE_HASH)"
else
echo " Ladataan $BINARY..."
fi
rm -f "$BIN_PATH"
curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
chmod +x "$BIN_PATH"
echo "$REMOTE_HASH" > "$HASH_PATH"
echo " ✓ Päivitetty versioon $REMOTE_HASH"
fi
echo ""
echo " ✓ Siirrytään Kipinä Noden hallintaan..."
echo " Ctrl+C pysäyttää"
echo ""
if [ -n "$KIPINA_MODEL" ]; then
export OLLAMA_MODEL="$KIPINA_MODEL"
fi
export HUB_URL="$HUB_URL"
export OLLAMA_URL="$OLLAMA_URL"
exec "$BIN_PATH"

View File

@@ -1,37 +0,0 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== Kipinä Studio Local Development ==="
# Frontend
echo "[1/3] Rakennetaan frontend..."
cd "$SCRIPT_DIR/frontend"
[ -d node_modules ] || npm install --silent
npm run build --silent 2>&1 | tail -1
# Hub
echo "[2/3] Käynnistetään hub..."
cd "$SCRIPT_DIR/hub"
cargo run &
HUB_PID=$!
sleep 3
# Native-node (jos Ollama on käynnissä)
if curl -s http://localhost:11434/api/tags >/dev/null 2>&1; then
echo "[3/3] Ollama löytyi — käynnistetään native-node..."
cd "$SCRIPT_DIR/native-node"
HUB_URL=ws://localhost:3000/ws cargo run --no-default-features &
NODE_PID=$!
echo " Native-node PID: $NODE_PID"
else
echo "[3/3] Ollama ei käynnissä — käytetään selaimen Wasm-laskentaa"
echo " Nopeampi: ollama serve & ollama pull qwen2.5-coder:7b && ./local.sh"
fi
echo ""
echo "=== http://localhost:3000 ==="
echo " Ctrl+C pysäyttää"
# Odotetaan hub-prosessia
wait $HUB_PID

View File

@@ -19,3 +19,8 @@ wgpu = { version = "24", optional = true }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dialoguer = "0.12.0"
ratatui = "0.29.0"
crossterm = { version = "0.28.1", features = ["event-stream"] }
tracing-appender = "0.2.4"
chrono = "0.4"

View File

@@ -1,6 +1,15 @@
use std::time::Instant; use std::time::Instant;
use std::cell::RefCell; use std::cell::RefCell;
pub struct GenerateOptions {
pub max_tokens: usize,
pub system_prompt: Option<String>,
pub temperature: Option<f64>,
pub top_k: Option<u64>,
pub repeat_penalty: Option<f64>,
pub stop: Option<Vec<String>>,
}
pub struct LlmEngine { pub struct LlmEngine {
ollama_url: String, ollama_url: String,
model: RefCell<String>, model: RefCell<String>,
@@ -9,8 +18,6 @@ pub struct LlmEngine {
impl LlmEngine { impl LlmEngine {
pub async fn load() -> Result<Self, String> { pub async fn load() -> Result<Self, String> {
let model = std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "qwen2.5-coder:3b".to_string());
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(600)) .timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(3)) .connect_timeout(std::time::Duration::from_secs(3))
@@ -48,6 +55,12 @@ impl LlmEngine {
}) })
}; };
// Kysytään malli TUI:lla jos ei pakotettu ympäristöstä
let model = match std::env::var("OLLAMA_MODEL") {
Ok(m) if !m.is_empty() => m,
_ => crate::tui::select_model(&ollama_url, &client).await?
};
tracing::info!("Ollama backend: {} | malli: {}", ollama_url, model); tracing::info!("Ollama backend: {} | malli: {}", ollama_url, model);
Ok(LlmEngine { ollama_url, model: RefCell::new(model), client }) Ok(LlmEngine { ollama_url, model: RefCell::new(model), client })
} }
@@ -56,6 +69,10 @@ impl LlmEngine {
self.model.borrow().clone() self.model.borrow().clone()
} }
pub fn ollama_url(&self) -> &str {
&self.ollama_url
}
pub fn set_model(&self, new_model: String) { pub fn set_model(&self, new_model: String) {
*self.model.borrow_mut() = new_model; *self.model.borrow_mut() = new_model;
} }
@@ -78,28 +95,82 @@ impl LlmEngine {
} }
} }
pub async fn generate(&self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> { /// Hakee käynnissä olevan mallin VRAM-tilan (ollama ps)
// System prompt tulee agentin konfiguraatiosta (frontend lähettää sen osana promptia). pub async fn fetch_ps(&self) -> Result<Option<ModelVramStatus>, String> {
// Tässä ei yliajeta sitä — Ollama saa vain prompt-kentän. let resp = self.client.get(format!("{}/api/ps", self.ollama_url))
let model = self.model.borrow().clone();
let start = Instant::now();
let resp = self.client.post(format!("{}/api/generate", self.ollama_url))
.json(&serde_json::json!({
"model": model,
"prompt": prompt,
"stream": false,
"options": {
"num_predict": max_tokens,
"temperature": 0.7,
"top_k": 40,
"repeat_penalty": 1.15,
"stop": ["<|im_end|>", "\n###", "\nExplanation", "\nNote:", "\nPlease note", "\nThis is", "\n```\n\n", "\n// Example", "\n# Example"]
}
}))
.send() .send()
.await .await
.map_err(|e| format!("Ollama generate: {}", e))?; .map_err(|e| format!("Ollama ps: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Ollama ps HTTP {}", resp.status()));
}
let body: serde_json::Value = resp.json().await
.map_err(|e| format!("Ollama ps json: {}", e))?;
let models = body["models"].as_array();
if let Some(arr) = models {
if let Some(m) = arr.first() {
let name = m["name"].as_str().unwrap_or("?").to_string();
let size = m["size"].as_u64().unwrap_or(0);
let size_vram = m["size_vram"].as_u64().unwrap_or(0);
return Ok(Some(ModelVramStatus { name, size, size_vram }));
}
}
Ok(None) // ei ladattua mallia
}
/// Hakee kaikki Ollamaan asennetut mallit
pub async fn fetch_models(&self) -> Result<serde_json::Value, String> {
let resp = self.client.get(format!("{}/api/tags", self.ollama_url))
.send()
.await
.map_err(|e| format!("Ollama tags fetch: {}", e))?;
if resp.status().is_success() {
resp.json().await.map_err(|e| format!("Ollama tags json: {}", e))
} else {
Err(format!("Ollama tags epäonnistui: {}", resp.status()))
}
}
pub async fn generate(&self, prompt: &str, opts: &GenerateOptions) -> Result<GenerateResult, String> {
let model = self.model.borrow().clone();
let default_stop: Vec<String> = vec![
"<|im_end|>".into(),
];
// Rakennetaan messages-lista (chat API)
let mut messages = Vec::new();
if let Some(ref sp) = opts.system_prompt {
if !sp.is_empty() {
messages.push(serde_json::json!({"role": "system", "content": sp}));
}
}
messages.push(serde_json::json!({"role": "user", "content": prompt}));
let body = serde_json::json!({
"model": model,
"messages": messages,
"stream": false,
"options": {
"num_ctx": 16384,
"num_predict": opts.max_tokens,
"temperature": opts.temperature.unwrap_or(0.7),
"top_k": opts.top_k.unwrap_or(40),
"repeat_penalty": opts.repeat_penalty.unwrap_or(1.15),
"stop": opts.stop.as_ref().unwrap_or(&default_stop),
}
});
let start = Instant::now();
let resp = self.client.post(format!("{}/api/chat", self.ollama_url))
.json(&body)
.send()
.await
.map_err(|e| format!("Ollama chat: {}", e))?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(format!("Ollama HTTP {}", resp.status())); return Err(format!("Ollama HTTP {}", resp.status()));
@@ -108,7 +179,7 @@ impl LlmEngine {
let body: serde_json::Value = resp.json().await let body: serde_json::Value = resp.json().await
.map_err(|e| format!("Ollama JSON: {}", e))?; .map_err(|e| format!("Ollama JSON: {}", e))?;
let text = body["response"].as_str().unwrap_or("").to_string(); let text = body["message"]["content"].as_str().unwrap_or("").to_string();
let _total_duration_ns = body["total_duration"].as_u64().unwrap_or(0); let _total_duration_ns = body["total_duration"].as_u64().unwrap_or(0);
let eval_count = body["eval_count"].as_u64().unwrap_or(0) as usize; let eval_count = body["eval_count"].as_u64().unwrap_or(0) as usize;
let eval_duration_ns = body["eval_duration"].as_u64().unwrap_or(1); let eval_duration_ns = body["eval_duration"].as_u64().unwrap_or(1);
@@ -127,40 +198,15 @@ impl LlmEngine {
} }
} }
/// Siivoa markdown-koodiblokki-merkit ja selitystekstit /// Siivoa markdown-koodiblokki-merkit vastauksesta
fn strip_code_fences(text: &str) -> String { fn strip_code_fences(text: &str) -> String {
// Poistetaan kaikki ```-rivit ja kielitunnisteet (```python, ```rust jne.)
let lines: Vec<&str> = text.lines().collect(); let lines: Vec<&str> = text.lines().collect();
let filtered: Vec<&str> = lines.into_iter().filter(|line| { let filtered: Vec<&str> = lines.into_iter().filter(|line| {
let trimmed = line.trim(); let trimmed = line.trim();
// Poista rivit jotka ovat pelkkiä ``` tai ```kielitunniste // Poista rivit jotka ovat pelkkiä ``` tai ```kielitunniste
if trimmed.starts_with("```") { trimmed != "```" && !(trimmed.starts_with("```") && !trimmed[3..].contains('`'))
return false;
}
true
}).collect(); }).collect();
let mut result = filtered.join("\n").trim().to_string(); filtered.join("\n").trim().to_string()
// Poista selitysteksti lopusta (kaikki rivin "\nPlease note" jälkeen jne.)
let lower = result.to_lowercase();
for stop in &["\nplease note", "\nthis is a basic", "\nthis code", "\nnote that", "\nremember to", "\nyou can", "\nto run"] {
if let Some(pos) = lower.find(stop) {
result = result[..pos].trim_end().to_string();
}
}
// Poista johdantolauseet alusta
let lower = result.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;
}
}
result.trim().to_string()
} }
pub struct GenerateResult { pub struct GenerateResult {
@@ -169,3 +215,32 @@ pub struct GenerateResult {
pub duration_ms: f64, pub duration_ms: f64,
pub tokens_per_sec: f64, pub tokens_per_sec: f64,
} }
pub struct ModelVramStatus {
pub name: String,
pub size: u64, // kokonaiskoko (tavuina)
pub size_vram: u64, // VRAM:ssa oleva osuus (tavuina)
}
impl ModelVramStatus {
pub fn fully_in_vram(&self) -> bool {
self.size > 0 && self.size_vram >= self.size
}
pub fn vram_percent(&self) -> f64 {
if self.size == 0 { return 0.0; }
(self.size_vram as f64 / self.size as f64) * 100.0
}
pub fn display(&self) -> String {
let size_gb = self.size as f64 / 1_073_741_824.0;
let vram_gb = self.size_vram as f64 / 1_073_741_824.0;
if self.fully_in_vram() {
format!("{} ({:.1} GB) — 100% GPU", self.name, size_gb)
} else if self.size_vram == 0 {
format!("{} ({:.1} GB) — 100% CPU", self.name, size_gb)
} else {
format!("{} ({:.1}/{:.1} GB VRAM, {:.0}% GPU)", self.name, vram_gb, size_gb, self.vram_percent())
}
}
}

View File

@@ -1,10 +1,13 @@
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde_json::json; use serde_json::json;
use std::io::IsTerminal;
use sysinfo::System; 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; mod inference;
mod tui;
mod tui_dashboard;
/// GPU-tietorakenne — yhtenäinen kaikille valmistajille /// GPU-tietorakenne — yhtenäinen kaikille valmistajille
struct GpuInfo { struct GpuInfo {
@@ -222,7 +225,7 @@ fn collect_system_info() -> serde_json::Value {
} }
/// Koko auth-viesti hubille /// Koko auth-viesti hubille
fn build_auth_message(allocated_gb: u32) -> String { fn build_auth_message(allocated_gb: u32, model_name: &str, models_data: Option<serde_json::Value>) -> String {
let sys = collect_system_info(); let sys = collect_system_info();
let gpus = collect_all_gpus(); let gpus = collect_all_gpus();
@@ -239,7 +242,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": "qwen2.5-coder:7b", "selected_task": model_name,
"system": sys, "system": sys,
}); });
@@ -251,6 +254,10 @@ fn build_auth_message(allocated_gb: u32) -> String {
msg.as_object_mut().unwrap().insert("gpus".to_string(), json!(gpu_json)); msg.as_object_mut().unwrap().insert("gpus".to_string(), json!(gpu_json));
} }
if let Some(models) = models_data {
msg.as_object_mut().unwrap().insert("models".to_string(), models);
}
msg.to_string() msg.to_string()
} }
@@ -263,10 +270,24 @@ fn format_optional<T: std::fmt::Display>(val: Option<T>, suffix: &str) -> String
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let file_appender = tracing_appender::rolling::never(".", "native-node.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter("native_node=debug") .with_env_filter("native_node=debug")
.with_writer(non_blocking)
.init(); .init();
// Hookataan paniikkitilanteet palauttamaan terminaalin raw-moodista
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
tui_dashboard::restore_terminal();
original_hook(panic_info);
}));
let tui_state = std::sync::Arc::new(tokio::sync::RwLock::new(tui_dashboard::DashboardState::new()));
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let hub_url = std::env::var("HUB_URL").unwrap_or_else(|_| "ws://hub:3000/ws".to_string()); let hub_url = std::env::var("HUB_URL").unwrap_or_else(|_| "ws://hub:3000/ws".to_string());
let allocated_gb: u32 = std::env::var("ALLOCATED_GB") let allocated_gb: u32 = std::env::var("ALLOCATED_GB")
.ok() .ok()
@@ -282,6 +303,18 @@ async fn main() {
sys["cpu_cores"], sys["cpu_cores"],
sys["ram_total_mb"] sys["ram_total_mb"]
); );
{
let mut st = tui_state.write().await;
st.sys_info = format!("{} | {} | {} ydintä | {} MB RAM",
sys["hostname"].as_str().unwrap_or("?"),
sys["os"].as_str().unwrap_or("?"),
sys["cpu_cores"],
sys["ram_total_mb"]
);
let i = st.sys_info.clone();
st.push_log("System", format!("Järjestelmä: {}", i), None);
}
let gpus = collect_all_gpus(); let gpus = collect_all_gpus();
if gpus.is_empty() { if gpus.is_empty() {
@@ -321,6 +354,82 @@ async fn main() {
} }
}; };
let active_model = llm.as_ref().map(|e| e.model_name()).unwrap_or_else(|| "unknown".to_string());
tracing::info!("Käytettävä kielimalli konfiguroitu (selected_task): {}", active_model);
{
let mut st = tui_state.write().await;
st.model_name = active_model.clone();
st.push_log("System", format!("Malli valmis: {}", active_model), None);
}
// Lämmittelykutsu: ladataan malli VRAM:iin ja haetaan VRAM-tila
if let Some(ref engine) = llm {
{
let mut st = tui_state.write().await;
st.vram_status = "Ladataan VRAM:iin...".to_string();
st.push_log("System", "Ladataan mallia VRAM:iin...".to_string(), None);
}
// Lyhyt generate-kutsu pakottaa Ollaman lataamaan mallin GPU:lle
let _ = engine.generate("hi", &inference::GenerateOptions {
max_tokens: 1, system_prompt: None, temperature: Some(0.0),
top_k: Some(1), repeat_penalty: None, stop: None,
}).await;
if let Ok(Some(ps)) = engine.fetch_ps().await {
let mut st = tui_state.write().await;
st.vram_status = ps.display();
st.push_log("System", format!("VRAM: {}", ps.display()), None);
}
let vram_engine_url = engine.ollama_url().to_string();
let vram_state = tui_state.clone();
tokio::spawn(async move {
let client = reqwest::Client::new();
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
if let Ok(resp) = client.get(format!("{}/api/ps", vram_engine_url)).send().await {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(arr) = body["models"].as_array() {
if let Some(m) = arr.first() {
let name = m["name"].as_str().unwrap_or("?").to_string();
let size = m["size"].as_u64().unwrap_or(0);
let size_vram = m["size_vram"].as_u64().unwrap_or(0);
let status = inference::ModelVramStatus { name, size, size_vram };
vram_state.write().await.vram_status = status.display();
} else {
vram_state.write().await.vram_status = "Ei ladattua mallia".to_string();
}
}
}
}
}
});
}
// Käynnistetään graafinen TUI vain jos stdin on terminaali (ei taustaprosessina)
let ui_state = tui_state.clone();
if std::io::stdin().is_terminal() {
tokio::spawn(async move {
if let Err(e) = tui_dashboard::run_dashboard(ui_state, cmd_tx).await {
tracing::error!("Pääluupin TUI kaatui: {}", e);
}
});
} else {
tracing::info!("Ei terminaalia — TUI ohitettu, lokitetaan stdoutiin");
};
// Haetaan paikalliset mallit hubille lähetettäväksi
let mut available_models = None;
if let Some(ref engine) = llm {
match engine.fetch_models().await {
Ok(models) => {
available_models = Some(models);
}
Err(e) => {
tracing::warn!("Mallilistauksen haku epäonnistui: {}", e);
}
}
}
// Yhdistetään hubiin // Yhdistetään hubiin
loop { loop {
match connect_async(&hub_url).await { match connect_async(&hub_url).await {
@@ -328,83 +437,266 @@ async fn main() {
tracing::info!("Yhdistetty hubiin!"); tracing::info!("Yhdistetty hubiin!");
let (mut write, mut read) = ws_stream.split(); let (mut write, mut read) = ws_stream.split();
let auth = build_auth_message(allocated_gb); let auth = build_auth_message(allocated_gb, &active_model, available_models.clone());
if write.send(Message::Text(auth)).await.is_err() { if write.send(Message::Text(auth)).await.is_err() {
tracing::error!("Auth-viestin lähetys epäonnistui"); tracing::error!("Auth-viestin lähetys epäonnistui");
continue; continue;
} }
while let Some(Ok(msg)) = read.next().await { // Merkitään yhdistetyksi TUI:ssa
if let Message::Text(text) = msg { {
// LLM-promptit let mut st = tui_state.write().await;
if text.contains("llm_prompt") { st.status = "ACTIVE".to_string();
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) { st.push_log("Network", "Yhdistetty hubiin".to_string(), None);
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); }
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
let msg_model = task.get("model").and_then(|v| v.as_str()).unwrap_or("");
if !prompt.is_empty() && (msg_model.starts_with("qwen-coder") || msg_model.starts_with("qwen2.5-coder")) {
loop {
tokio::select! {
cmd = cmd_rx.recv() => {
if let Some(cmd_str) = cmd {
if cmd_str == "pause" {
tracing::info!("Tauotetaan solmun suoritus (Hub ei lähetä tehtäviä)...");
let req = json!({"type": "status_update", "status": "paused"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "PAUSED".to_string();
st.push_log("Network", "Solmu siirretty taukotilaan".to_string(), None);
}
} else if cmd_str == "resume" {
tracing::info!("Jatketaan solmun suoritusta...");
let req = json!({"type": "status_update", "status": "active"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("System", "Suoritus jatkuu...".to_string(), None);
}
} else if cmd_str == "fetch_models" {
// Haetaan mallit Ollamasta ja avataan valikkö
if let Some(ref engine) = llm { if let Some(ref engine) = llm {
let max_tokens = task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(1024) as usize; match engine.fetch_models().await {
let prompt_lines = prompt.lines().count(); Ok(tags) => {
let prompt_last: String = prompt.lines().last().unwrap_or("").chars().take(60).collect(); let models: Vec<String> = tags.get("models")
tracing::info!("→ task_id:{} | {}r prompti | \"{}...\"", task_id, prompt_lines, prompt_last); .and_then(|v| v.as_array())
.map(|arr| arr.iter()
let model_name = engine.model_name(); .filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
match engine.generate(prompt, max_tokens).await { .collect())
Ok(result) => { .unwrap_or_default();
tracing::info!( let mut st = tui_state.write().await;
"✓ {} | {} tok | {:.0}ms | {:.1} tok/s", st.model_picker_items = models;
model_name, st.model_picker_idx = 0;
result.tokens_generated, st.model_picker_open = true;
result.duration_ms,
result.tokens_per_sec,
);
// Lähetetään vain lyhyt prompti-esikatselu (ei koko kontekstia)
let prompt_short: String = prompt.lines().last().unwrap_or("").chars().take(100).collect();
let done = json!({
"type": "llm_done",
"prompt": prompt_short,
"model": format!("{} (Ollama)", model_name),
"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) => { Err(e) => {
tracing::error!("Inferenssivirhe: {}", e); let mut st = tui_state.write().await;
st.push_log("System", format!("Mallilistan haku epäonnistui: {}", e), None);
}
}
}
} else if let Some(model) = cmd_str.strip_prefix("change_model:") {
// TUI:sta valittu malli — vaihdetaan
if let Some(ref engine) = llm {
engine.set_model(model.to_string());
match engine.ensure_model().await {
Ok(()) => {
tracing::info!("Malli vaihdettu: {}", model);
let mut st = tui_state.write().await;
st.model_name = model.to_string();
st.push_log("System", format!("Malli vaihdettu: {}", model), None);
// Ilmoitetaan hubille
let auth = build_auth_message(allocated_gb, model, available_models.clone());
let _ = write.send(Message::Text(auth)).await;
}
Err(e) => {
let mut st = tui_state.write().await;
st.push_log("System", format!("Mallin vaihto epäonnistui: {}", e), None);
} }
} }
} }
} }
} }
} }
// Mallin vaihto lennossa ws_msg = read.next() => {
if text.contains("change_model") { match ws_msg {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) { Some(Ok(Message::Text(text))) => {
if let Some(new_model) = task.get("model").and_then(|v| v.as_str()) { // Hubin control-viestit
if let Some(ref engine) = llm { if text.contains(r#""type":"control""#) {
tracing::info!("Vaihdetaan malli: {}", new_model); if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
engine.set_model(new_model.to_string()); if let Some(action) = task.get("action").and_then(|v| v.as_str()) {
match engine.ensure_model().await { if action == "pause" {
Ok(()) => tracing::info!("Malli {} valmis!", new_model), tracing::info!("Hub pakotti solmun tauolle (Pause)");
Err(e) => tracing::error!("Mallin lataus epäonnistui: {}", e), let req = json!({"type": "status_update", "status": "paused"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "PAUSED".to_string();
st.push_log("Network", "Hub kytki solmun tauolle".to_string(), None);
}
} else if action == "resume" {
tracing::info!("Hub aktivoi solmun suorituksen (Resume)");
let req = json!({"type": "status_update", "status": "active"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("Network", "Hub palautti solmun töihin".to_string(), None);
}
}
}
}
}
// Node joined → oma node_id
if text.contains(r#""type":"node_joined""#) {
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(nid) = msg.get("node_id").and_then(|v| v.as_u64()) {
let mut st = tui_state.write().await;
if st.node_id.is_none() {
st.node_id = Some(nid);
st.push_log("Network", format!("Node ID: #{}", nid), None);
}
}
}
}
// Verkon globaali tila
if text.contains(r#""type":"network_status""#) {
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(nodes) = status.get("active_nodes").and_then(|v| v.as_u64()) {
if let Some(tasks) = status.get("tasks").and_then(|v| v.as_u64()) {
let mut st = tui_state.write().await;
st.network_active_nodes = nodes as usize;
st.network_total_tasks = tasks;
}
}
}
}
// LLM-promptit
if text.contains("llm_prompt") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
let msg_model = task.get("model").and_then(|v| v.as_str()).unwrap_or("");
if !prompt.is_empty() && (msg_model.starts_with("qwen-coder") || msg_model.starts_with("qwen2.5-coder") || msg_model.starts_with("phi")) {
if let Some(ref engine) = llm {
let gen_opts = inference::GenerateOptions {
max_tokens: task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(1024) as usize,
system_prompt: task.get("system_prompt").and_then(|v| v.as_str()).map(|s| s.to_string()),
temperature: task.get("temperature").and_then(|v| v.as_f64()),
top_k: task.get("top_k").and_then(|v| v.as_u64()),
repeat_penalty: task.get("repeat_penalty").and_then(|v| v.as_f64()),
stop: task.get("stop").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(|s| s.to_string())).collect()),
};
let prompt_lines = prompt.lines().count();
let prompt_last: String = prompt.lines().last().unwrap_or("").chars().take(60).collect();
tracing::info!("→ task_id:{} | {}r prompti | \"{}...\"", task_id, prompt_lines, prompt_last);
{
let mut st = tui_state.write().await;
st.cur_task_id = Some(task_id.to_string());
st.cur_prompt = Some(format!("{} riviä | \"{}...\"", prompt_lines, prompt_last));
}
let model_name = engine.model_name();
match engine.generate(prompt, &gen_opts).await {
Ok(result) => {
let tokens_sec = (result.tokens_per_sec * 10.0).round() / 10.0;
tracing::info!(
"✓ {} | {} tok | {:.0}ms | {:.1} tok/s",
model_name,
result.tokens_generated,
result.duration_ms,
tokens_sec,
);
{
let mut st = tui_state.write().await;
st.tasks_completed += 1;
st.last_tokens_sec = tokens_sec as f64;
st.cur_task_id = None;
st.cur_prompt = None;
let msg_type = if task_id == "status-check" { "Ping" } else { "Task" };
let msg_text = format!("{} ({} tok)", task_id, result.tokens_generated);
st.push_log(msg_type, msg_text, Some(tokens_sec as f64));
}
let prompt_short: String = prompt.lines().last().unwrap_or("").chars().take(100).collect();
let done = json!({
"type": "llm_done",
"prompt": prompt_short,
"model": format!("{} (Ollama)", model_name),
"response": result.text,
"tokens_generated": result.tokens_generated,
"duration_ms": result.duration_ms,
"tokens_per_sec": tokens_sec,
"load_time_ms": 0,
"task_id": task_id,
});
let _ = write.send(Message::Text(done.to_string())).await;
}
Err(e) => {
tracing::error!("Inferenssivirhe: {}", e);
{
let mut st = tui_state.write().await;
st.cur_task_id = None;
st.cur_prompt = None;
st.push_log("System", format!("Virhe inferenssissä: {}", e), None);
}
}
}
}
}
}
}
// Mallin vaihto lennossa
if text.contains("change_model") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(new_model) = task.get("model").and_then(|v| v.as_str()) {
if let Some(ref engine) = llm {
tracing::info!("Vaihdetaan malli: {}", new_model);
engine.set_model(new_model.to_string());
match engine.ensure_model().await {
Ok(()) => {
tracing::info!("Malli {} valmis!", new_model);
let mut st = tui_state.write().await;
st.model_name = new_model.to_string();
st.push_log("System", format!("Malli {} ladattu & valmis!", new_model), None);
}
Err(e) => tracing::error!("Mallin lataus epäonnistui: {}", e),
}
}
}
} }
} }
} }
Some(Ok(_)) => {} // Muut viestityypit (binary/ping)
Some(Err(_)) | None => break, // Yhteys poikki
} }
} }
} }
} }
// Yhteys katkesi — nollataan TUI:n busy-tila
{
let mut st = tui_state.write().await;
let lost_task = st.cur_task_id.clone();
if let Some(tid) = lost_task {
st.push_log("Network", format!("Tehtävä {} keskeytyi yhteyden katketessa", tid), None);
}
st.cur_task_id = None;
st.cur_prompt = None;
st.node_id = None;
st.status = "RECONNECTING".to_string();
st.push_log("Network", "Yhteys hubiin katkesi — yhdistetään uudelleen 5s...".to_string(), None);
}
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s..."); tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
} }
Err(e) => { Err(e) => {
{
let mut st = tui_state.write().await;
st.status = "RECONNECTING".to_string();
st.push_log("Network", format!("Yhdistäminen epäonnistui: {} — yritetään 5s...", e), None);
}
tracing::warn!("Hubiin yhdistäminen epäonnistui: {} — yritetään uudelleen 5s...", e); tracing::warn!("Hubiin yhdistäminen epäonnistui: {} — yritetään uudelleen 5s...", e);
} }
} }

View File

@@ -0,0 +1,67 @@
use dialoguer::{Select, Input, theme::ColorfulTheme};
use reqwest::Client;
pub async fn select_model(ollama_url: &str, client: &Client) -> Result<String, String> {
// 1. Hae tagit
let mut models = vec![];
println!(" Haetaan asennettuja malleja osoitteesta {}...", ollama_url);
if let Ok(resp) = client.get(&format!("{}/api/tags", ollama_url)).send().await {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if let Some(arr) = json.get("models").and_then(|v| v.as_array()) {
for m in arr {
if let Some(name) = m.get("name").and_then(|v| v.as_str()) {
models.push(name.to_string());
}
}
}
}
}
}
let download_opt = "[ Lataa uusi malli internetistä]";
let mut options = vec![download_opt.to_string()];
options.extend(models);
// 2. Kysy käyttäjältä Selectillä
let theme = ColorfulTheme::default();
let selection = Select::with_theme(&theme)
.with_prompt("Valitse Ollama-malli Kipinä-verkkoa varten:")
.default(if options.len() > 1 { 1 } else { 0 })
.items(&options)
.interact()
.map_err(|e| format!("TUI virhe: {}", e))?;
let selected = &options[selection];
// 3. Jos käyttäjä haluaa uuden, kysy nimeä
if selected == download_opt {
let new_model: String = Input::with_theme(&theme)
.with_prompt("Syötä ladattavan mallin nimi (esim. llama3 tai qwen2.5-coder:3b)")
.interact_text()
.map_err(|e| format!("TUI virhe: {}", e))?;
let new_model = new_model.trim().to_string();
if new_model.is_empty() {
return Err("Mallin nimi ei voi olla tyhjä".to_string());
}
println!(" Ladataan malleja taustalla... Tämä voi kestää hetken ({})", new_model);
// Odotetaan että pull on valmis
let pull_body = serde_json::json!({ "name": &new_model });
let resp = client.post(&format!("{}/api/pull", ollama_url))
.json(&pull_body)
.send()
.await
.map_err(|e| format!("Pull req virhe: {}", e))?;
if resp.status().is_success() {
println!(" ✓ Malli {} ladattu onnistuneesti!", new_model);
return Ok(new_model);
} else {
return Err(format!("Ollama pull epäonnistui: {}", resp.status()));
}
}
Ok(selected.clone())
}

View File

@@ -0,0 +1,330 @@
use crossterm::{
event::{Event, EventStream, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Alignment},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph, Wrap},
Terminal,
};
use std::io;
use tokio::sync::RwLock;
use std::sync::Arc;
use futures_util::StreamExt;
use std::time::Duration;
#[derive(Clone)]
pub struct LogEntry {
pub ty: String,
pub msg: String,
pub speed: Option<f64>,
pub timestamp: String,
}
pub struct DashboardState {
pub logs: Vec<LogEntry>,
pub status: String,
pub node_id: Option<u64>,
pub sys_info: String,
pub model_name: String,
pub cur_task_id: Option<String>,
pub cur_prompt: Option<String>,
pub tasks_completed: u32,
pub last_tokens_sec: f64,
pub network_active_nodes: usize,
pub network_total_tasks: u64,
// VRAM-tila (ollama ps)
pub vram_status: String,
// Mallivalikko
pub model_picker_open: bool,
pub model_picker_items: Vec<String>,
pub model_picker_idx: usize,
}
impl DashboardState {
pub fn new() -> Self {
Self {
logs: Vec::new(),
status: "ACTIVE".to_string(),
node_id: None,
sys_info: "".to_string(),
model_name: "Yhdistetään...".to_string(),
cur_task_id: None,
cur_prompt: None,
tasks_completed: 0,
last_tokens_sec: 0.0,
network_active_nodes: 1, // oletetaan itsemme
network_total_tasks: 0,
vram_status: "Haetaan...".to_string(),
model_picker_open: false,
model_picker_items: Vec::new(),
model_picker_idx: 0,
}
}
pub fn push_log(&mut self, ty: &str, msg: String, speed: Option<f64>) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
self.logs.push(LogEntry {
timestamp: now,
ty: ty.to_string(),
msg,
speed,
});
if self.logs.len() > 100 {
self.logs.remove(0);
}
}
}
pub async fn run_dashboard(
state: Arc<RwLock<DashboardState>>,
cmd_tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<(), io::Error> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let mut reader = EventStream::new();
let mut interval = tokio::time::interval(Duration::from_millis(100));
loop {
tokio::select! {
_ = interval.tick() => {
let st = state.read().await;
terminal.draw(|f| ui(f, &st))?;
}
ev = reader.next() => {
if let Some(Ok(Event::Key(key))) = ev {
let picker_open = state.read().await.model_picker_open;
if picker_open {
// Mallivalikko auki — navigointi
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
let mut st = state.write().await;
if st.model_picker_idx > 0 { st.model_picker_idx -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
let mut st = state.write().await;
let max = st.model_picker_items.len().saturating_sub(1);
if st.model_picker_idx < max { st.model_picker_idx += 1; }
}
KeyCode::Enter => {
let mut st = state.write().await;
let idx = st.model_picker_idx;
if let Some(model) = st.model_picker_items.get(idx).cloned() {
st.model_picker_open = false;
st.push_log("System", format!("Vaihdetaan malliin: {}...", model), None);
let _ = cmd_tx.send(format!("change_model:{}", model));
}
}
KeyCode::Esc | KeyCode::Char('m') | KeyCode::Char('M') => {
state.write().await.model_picker_open = false;
}
_ => {}
}
} else {
// Normaali tila
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
std::process::exit(0);
}
KeyCode::Char('p') | KeyCode::Char('P') => {
let _ = cmd_tx.send("pause".to_string());
}
KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::Char('s') => {
let _ = cmd_tx.send("resume".to_string());
}
KeyCode::Char('m') | KeyCode::Char('M') => {
let _ = cmd_tx.send("fetch_models".to_string());
}
_ => {}
}
}
}
}
}
}
}
pub fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Body
Constraint::Length(3), // Footer / Status
].as_ref())
.split(f.area());
// --- Header ---
let header_text = match st.node_id {
Some(id) => format!(" Kipinä Agentic Node #{} ", id),
None => " Kipinä Agentic Node (Yhdistää...) ".to_string(),
};
let header = Paragraph::new(header_text)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).style(Style::default().fg(Color::DarkGray)));
f.render_widget(header, chunks[0]);
// --- Body ---
let body_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), // Yläosan info ja tehtävä
Constraint::Min(0), // Lokit / Chat alas
].as_ref())
.split(chunks[1]);
let top_panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40), // Vasen paneeli (Info)
Constraint::Percentage(60), // Oikea paneeli (Tehtävä)
].as_ref())
.split(body_chunks[0]);
// Vasen paneeli: Laitteisto, Malli & Verkosto — VRAM-rivi värikoodattu
let vram_color = if st.vram_status.starts_with('✓') {
Color::Green
} else if st.vram_status.starts_with('◐') {
Color::Yellow
} else if st.vram_status.starts_with('✗') {
Color::Red
} else {
Color::DarkGray
};
let info_lines = vec![
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("🚀 Malli: "),
ratatui::text::Span::styled(&st.model_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
]),
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("🎮 VRAM: "),
ratatui::text::Span::styled(&st.vram_status, Style::default().fg(vram_color)),
]),
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("💻 Järjestelmä: "),
ratatui::text::Span::styled(&st.sys_info, Style::default().fg(Color::White)),
]),
ratatui::text::Line::from(format!(
"📊 Tehdyt: {} | Nopeus: {:.1} t/s", st.tasks_completed, st.last_tokens_sec
)),
ratatui::text::Line::from(format!(
"🌐 Verkosto: {} solmua | {} tehtävää", st.network_active_nodes, st.network_total_tasks
)),
];
let left_panel = Paragraph::new(info_lines)
.block(Block::default().title(" Laitteisto ja AI ").borders(Borders::ALL))
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: true });
f.render_widget(left_panel, top_panels[0]);
// Oikea paneeli: Käynnissä oleva tehtävä
let task_title = match &st.cur_task_id {
Some(id) => format!(" Työn alla: {} ", id),
None => " Vapaana ".to_string(),
};
let task_content = st.cur_prompt.clone().unwrap_or_else(|| "Odotetaan tehtäviä Hubilta...".to_string());
let task_style = if st.cur_task_id.is_some() {
Style::default().fg(Color::Magenta)
} else {
Style::default().fg(Color::DarkGray)
};
let task_panel = Paragraph::new(task_content)
.wrap(Wrap { trim: true })
.block(Block::default().title(task_title).borders(Borders::ALL).style(task_style));
f.render_widget(task_panel, top_panels[1]);
// Alaosan paneeli: Tapahtumaloki koko leveydeltä
let area_height = body_chunks[1].height.saturating_sub(2) as usize;
let skip_count = if st.logs.len() > area_height { st.logs.len() - area_height } else { 0 };
let visible_logs: Vec<ratatui::text::Line> = st.logs.iter().skip(skip_count).map(|log| {
let ty_color = match log.ty.as_str() {
"System" => Color::Yellow,
"Network" => Color::Blue,
"Task" => Color::Magenta,
"Ping" => Color::DarkGray,
_ => Color::White,
};
let speed_str = if let Some(s) = log.speed {
format!(" | {:.1} tok/s", s)
} else {
"".to_string()
};
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(&log.timestamp, Style::default().fg(Color::DarkGray)),
ratatui::text::Span::raw(" "),
ratatui::text::Span::styled(format!("{: <8}", log.ty), Style::default().fg(ty_color).add_modifier(Modifier::BOLD)),
ratatui::text::Span::raw(" | "),
ratatui::text::Span::styled(log.msg.clone(), Style::default().fg(Color::White)),
ratatui::text::Span::styled(speed_str, Style::default().fg(ty_color)),
])
}).collect();
let logs_panel = Paragraph::new(visible_logs)
.block(Block::default().title(" Tapahtumaloki ").borders(Borders::ALL).style(Style::default().fg(Color::Cyan)));
f.render_widget(logs_panel, body_chunks[1]);
// --- Footer / Status ---
let status_color = if st.status == "ACTIVE" { Color::Green } else { Color::Yellow };
let status_text = format!(" Tila: {} | [P] Pause [R] Työhön [M] Malli [Q] Sulje ", st.status);
let footer = Paragraph::new(status_text)
.style(Style::default().fg(status_color).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(footer, chunks[2]);
// --- Mallivalikko-overlay ---
if st.model_picker_open && !st.model_picker_items.is_empty() {
let area = f.area();
let popup_h = (st.model_picker_items.len() as u16 + 4).min(area.height - 4);
let popup_w = 50.min(area.width - 4);
let popup = ratatui::layout::Rect::new(
(area.width - popup_w) / 2,
(area.height - popup_h) / 2,
popup_w,
popup_h,
);
// Tausta
f.render_widget(ratatui::widgets::Clear, popup);
let items: Vec<ratatui::text::Line> = st.model_picker_items.iter().enumerate().map(|(i, name)| {
if i == st.model_picker_idx {
ratatui::text::Line::from(format!("{} ", name))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
} else {
ratatui::text::Line::from(format!(" {} ", name))
.style(Style::default().fg(Color::White))
}
}).collect();
let picker = Paragraph::new(items)
.block(Block::default()
.title(" Vaihda malli [↑↓] Enter=valitse Esc=peruuta ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan)));
f.render_widget(picker, popup);
}
}

View File

@@ -10,32 +10,22 @@ crate-type = ["cdylib"]
wasm-bindgen = "0.2.91" wasm-bindgen = "0.2.91"
js-sys = "0.3.68" js-sys = "0.3.68"
web-sys = { version = "0.3.68", features = [ web-sys = { version = "0.3.68", features = [
"Window",
"Document",
"HtmlElement",
"WebSocket", "WebSocket",
"MessageEvent", "MessageEvent",
"Performance", "Performance",
"console", "console",
"Request",
"RequestInit",
"Response", "Response",
"Headers",
"ReadableStream", "ReadableStream",
"ReadableStreamDefaultReader", "ReadableStreamDefaultReader",
] } ] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
burn = { version = "0.14.0", features = ["wgpu", "ndarray"] }
burn-wgpu = "0.14.0"
burn-ndarray = "0.14.0"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
reqwest = { version = "0.12", default-features = false, features = ["json"] } 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" candle-core = "0.8"
candle-core = { version = "0.8" }
candle-nn = "0.8" candle-nn = "0.8"
candle-transformers = "0.8" candle-transformers = "0.8"
getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3", features = ["wasm_js"] }

View File

@@ -1,118 +0,0 @@
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)
}
}

View File

@@ -1,28 +0,0 @@
#[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,
}
}
}

View File

@@ -1,90 +0,0 @@
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)
}

View File

@@ -1,6 +0,0 @@
pub mod attention;
pub mod config;
pub mod loader;
pub mod model;
pub mod modules;
pub mod rope;

View File

@@ -1,96 +0,0 @@
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())
}
}

View File

@@ -1,59 +0,0 @@
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())
}
}

View File

@@ -1,59 +0,0 @@
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)
}
}

View File

@@ -3,16 +3,11 @@ 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};
use burn::tensor::Tensor;
use burn::backend::{Wgpu, NdArray};
pub mod storage; pub mod storage;
pub mod sampling; pub mod sampling;
pub mod smollm;
pub mod qwen; pub mod qwen;
pub mod qwen_coder; pub mod qwen_coder;
pub mod phi3;
pub mod burn_smollm;
#[macro_export] #[macro_export]
macro_rules! console_log { macro_rules! console_log {
@@ -82,41 +77,6 @@ pub async fn worker_fetch(url: &str) -> Result<web_sys::Response, String> {
.map_err(|_| "ei Response".to_string()) .map_err(|_| "ei Response".to_string())
} }
// Geneerinen tensorilaskenta — toimii millä tahansa Burn-backendillä
fn run_matmul<B: burn::tensor::backend::Backend>(size: usize) -> String {
let device = Default::default();
let dist = burn::tensor::Distribution::Default;
let t1: Tensor<B, 2> = Tensor::random([size, size], dist, &device);
let t2: Tensor<B, 2> = Tensor::random([size, size], dist, &device);
let sum = t1.matmul(t2).sum();
format!("{:?}", sum)
}
// Päättelyfunktio — valitsee backendin automaattisesti
async fn run_ai_tensor_inference(difficulty: usize) -> String {
let load_pct = GPU_LOAD_PERCENT.load(Ordering::SeqCst);
if load_pct == 0 {
sleep_ms(2000).await;
return format!("Paused (0%). Lepäillään zZz..");
}
let active_workload_size = (difficulty as f32 * (load_pct as f32 / 100.0)) as usize;
let sleep_delay = (100 - load_pct) * 10;
if sleep_delay > 0 {
sleep_ms(sleep_delay as i32).await;
}
let use_gpu = HAS_WEBGPU.load(Ordering::SeqCst);
let (backend_name, result) = if use_gpu {
("WebGPU", run_matmul::<Wgpu>(active_workload_size))
} else {
("CPU/NdArray", run_matmul::<NdArray>(active_workload_size))
};
format!("PoC {} Matmul ({}x{}) >> {}", backend_name, active_workload_size, active_workload_size, result)
}
/// JS-exportti: tokenisoi tekstin ja palauttaa JSON-merkkijonon /// JS-exportti: tokenisoi tekstin ja palauttaa JSON-merkkijonon
/// Tokenizer ladataan IndexedDB:stä (täytyy olla ladattu aiemmin) /// Tokenizer ladataan IndexedDB:stä (täytyy olla ladattu aiemmin)
@@ -246,7 +206,7 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
HAS_WEBGPU.store(has_webgpu, Ordering::SeqCst); HAS_WEBGPU.store(has_webgpu, Ordering::SeqCst);
SELECTED_TASK.store(task_id, 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)" };
let task_names = ["tokenize", "smollm-135m", "qwen-05b", "phi3-mini", "qwen-coder-05b", "qwen-coder-3b"]; let task_names = ["tokenize", "qwen-05b", "qwen-coder-05b", "qwen-coder-3b"];
let task_name = task_names.get(task_id as usize).unwrap_or(&"tokenize"); 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); console_log!("Kipinä Agent Node käynnistyy — backend: {} | tehtävä: {}", backend_name, task_name);
@@ -303,22 +263,6 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
} }
} }
} else if msg.contains("llm_prompt") && current_task == 1 && auto_on { } 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 // Qwen2.5-0.5B
if LLM_BUSY.load(Ordering::SeqCst) { if LLM_BUSY.load(Ordering::SeqCst) {
} else if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) { } else if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
@@ -333,21 +277,6 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
}); });
} }
} }
} 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") { } else if msg.contains("llm_prompt") {
console_log!("[DEBUG] llm_prompt vastaanotettu! current_task={}, busy={}", current_task, LLM_BUSY.load(Ordering::SeqCst)); console_log!("[DEBUG] llm_prompt vastaanotettu! current_task={}, busy={}", current_task, LLM_BUSY.load(Ordering::SeqCst));
if current_task == 4 || current_task == 5 { if current_task == 4 || current_task == 5 {
@@ -368,28 +297,23 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
let _ = ws_clone.borrow().send_with_str(&err_msg.to_string()); let _ = ws_clone.borrow().send_with_str(&err_msg.to_string());
} }
} else { } else {
// Välitetään parametrit JSON-promptina coderille
let coder_prompt = serde_json::json!({
"prompt": prompt,
"system": task.get("system_prompt").and_then(|v| v.as_str()).unwrap_or(""),
"max_tokens": task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(512),
}).to_string();
let use_3b = current_task == 5; let use_3b = current_task == 5;
LLM_BUSY.store(true, Ordering::SeqCst); LLM_BUSY.store(true, Ordering::SeqCst);
let ws_for_async = ws_clone.clone(); let ws_for_async = ws_clone.clone();
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
qwen_coder::run_coder_inference(prompt, ws_for_async, use_3b, task_id).await; qwen_coder::run_coder_inference(coder_prompt, ws_for_async, use_3b, task_id).await;
LLM_BUSY.store(false, Ordering::SeqCst); LLM_BUSY.store(false, Ordering::SeqCst);
}); });
} }
} }
} }
} // current_task == 4 || 5 } // current_task == 4 || 5
} else if msg.contains("ai_task") {
console_log!("Hub task vastaanotettu, ajetaan GPU:lla...");
let ws_for_async = ws_clone.clone();
let diff = if msg.contains(r#""difficulty":1024"#) { 1024 } else { 512 };
// Suoritetaan inference asynkronisesti erillisessä taaskissa välttääksemme UI-jäätymisen kokonaan
wasm_bindgen_futures::spawn_local(async move {
let result = run_ai_tensor_inference(diff).await;
let reply = format!("{{\"type\":\"result\", \"status\":\"success\", \"data\":\"{}\"}}", result);
let _ = ws_for_async.borrow().send_with_str(&reply);
});
} else if msg.contains("stats") { } else if msg.contains("stats") {
// Sivuutetaan statsit täällä, UI hallitsee ne aivan itse HTML:n puolella // Sivuutetaan statsit täällä, UI hallitsee ne aivan itse HTML:n puolella
} }

View File

@@ -1,36 +0,0 @@
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());
}

View File

@@ -1,232 +0,0 @@
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 resp = crate::worker_fetch(url).await?;
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>>) {
// performance via crate::perf_now()
// 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).await;
}
async fn run_burn_inference<B: burn::tensor::backend::Backend>(
prompt: String,
model_bytes: Vec<u8>,
tokenizer: tokenizers::Tokenizer,
ws: Rc<RefCell<WebSocket>>,
) {
let start_load = crate::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 = crate::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 = crate::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 = crate::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());
}

Binary file not shown.

View File

@@ -0,0 +1,513 @@
#!/usr/bin/env node
/**
* Kipinä Model Benchmark
*
* Generoi projekteja eri Ollama-malleilla ja testaa niiden toimivuus.
* Käyttö:
* node model-benchmark.mjs # kaikki mallit, oletusskenaario
* node model-benchmark.mjs --models qwen3:8b,qwen3:30b
* node model-benchmark.mjs --ollama http://host:11434
* node model-benchmark.mjs --scenarios all # kaikki skenaariot
*/
import { execSync } from 'child_process';
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
// === CLI-argumentit ===
const args = process.argv.slice(2);
function arg(name, fallback) {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
}
const OLLAMA_URL = arg('ollama', process.env.OLLAMA_URL || 'http://localhost:11434');
const HUB_URL = arg('hub', ''); // Vaihtoehto: --hub https://kipina.studio
const FILTER_MODELS = arg('models', '');
const SCENARIO_FILTER = arg('scenarios', 'default');
const OUTPUT_DIR = arg('output', '/tmp/kipina-benchmark');
const MAX_FIX_ROUNDS = 2;
// === Ollama / Hub -client ===
async function ollamaChat(model, prompt, systemPrompt, maxTokens = 2048) {
const start = Date.now();
if (HUB_URL) {
// Hub-reitti: /api/v1/chat/completions
const taskId = `bench-${Date.now()}-${Math.random().toString(36).slice(2,8)}`;
const resp = await fetch(`${HUB_URL}/api/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, task_id: taskId, system_prompt: systemPrompt, max_tokens: maxTokens }),
});
if (!resp.ok) throw new Error(`Hub HTTP ${resp.status}: ${await resp.text()}`);
const data = await resp.json();
const elapsed = Date.now() - start;
return {
text: (data.response || '').trim(),
tokens: data.tokens_generated || 0,
durationMs: elapsed,
tokPerSec: data.tokens_per_sec || (data.tokens_generated || 0) / (elapsed / 1000),
};
}
// Suora Ollama-reitti: /api/chat
const messages = [];
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
messages.push({ role: 'user', content: prompt });
const resp = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: { num_predict: maxTokens, temperature: 0.7, top_k: 40, repeat_penalty: 1.15 },
}),
});
if (!resp.ok) throw new Error(`Ollama HTTP ${resp.status}: ${await resp.text()}`);
const data = await resp.json();
const elapsed = Date.now() - start;
const text = (data.message?.content || '').trim();
const evalCount = data.eval_count || 0;
const evalDurationNs = data.eval_duration || 1;
const tokPerSec = evalCount / (evalDurationNs / 1e9);
return { text, tokens: evalCount, durationMs: elapsed, tokPerSec };
}
async function ollamaListModels() {
const url = HUB_URL ? `${HUB_URL}/api/v1/ollama/tags` : `${OLLAMA_URL}/api/tags`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Tags: HTTP ${resp.status}`);
const data = await resp.json();
return (data.models || []).map(m => m.name);
}
// === Promptit (kopioitu index.astrosta) ===
const CLIENT_SYSTEM = `You are a product owner who turns vague ideas into clear, actionable software requirements.
GIVEN a short project description from the user, produce a structured brief:
1. PROJECT NAME: a short, descriptive name
2. GOAL: one sentence explaining what the software does and who it's for
3. CORE FEATURES: numbered list of 3-8 concrete features (not vague wishes)
4. DATA MODEL: list the main entities and their key fields (include field types)
5. API ENDPOINTS: list the REST endpoints (method + path + purpose)
6. CONSTRAINTS: any technical constraints (e.g. "must use SQLite", "no auth needed")
RULES:
- Be specific: "User can filter todos by status" not "todo management"
- Use plain English, no code
- Maximum 400 words total`;
const SPEC_SYSTEM = `You are a software architect who designs database schemas for Python web applications.
THINK STEP BY STEP before outputting JSON:
1. What are the main ENTITIES (nouns) in this project?
2. What FIELDS does each entity need? (name, type, required?)
3. Which entities REFERENCE each other? (e.g. "a Book belongs to an Author" → Book has author_id)
4. Are there Date/DateTime fields? → add extra_imports
Then output ONLY valid JSON (no explanations before or after).
SCHEMA:
{"project_name":"short-name","description":"One sentence","entities":[{"name":"EntityName","table_name":"entity_names","fields":[{"name":"field_name","sa_type":"String(255)","py_type":"str","nullable":false,"default":null}]}],"relationships":[{"from":"ChildEntity","field":"parent_id","to":"ParentEntity","type":"many-to-one"}],"extra_imports":[]}
FIELD RULES:
- sa_type: String(N), Text, Integer, Date, DateTime, Boolean, Float
- py_type: str, int, float, bool, date, datetime — append " | None" if nullable
- Status fields: use String(20) with default value, NEVER Enum
- Every entity gets "id" automatically — do NOT add id or redundant ID fields
- Use snake_case for field names
RELATIONSHIP RULES:
- If entity A "belongs to" entity B → A has b_id field (Integer, nullable=false) + relationship entry
- EVERY _id field MUST have a matching relationship entry
- Parent entities must appear BEFORE children in the entities array
- If no relationships, set "relationships": []
AVOID: redundant ID fields, generic names, more than 7 fields or 3 entities, non-English entity/field names (ALWAYS English even if description is Finnish)
EXAMPLES (adapt, don't copy):
Todo app → Todo: title(str), description(Text|None), due_date(Date|None), status(String20="pending")
Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_id→Author, published_at(DateTime|None), status(String20="draft")`;
const FIX_SYSTEM = 'You are a Python code fixer. Return ONLY the corrected Python file. No markdown fences, no explanations — just valid Python code.';
// === Template-funktiot (kopioitu korjatusta index.astrosta) ===
function pyLiteral(val) {
if (val === true) return 'True';
if (val === false) return 'False';
if (val === null || val === undefined) return 'None';
if (typeof val === 'string') return `"${val}"`;
return String(val);
}
function pyJsonLiteral(obj) {
const parts = Object.entries(obj).map(([k, v]) => {
let pyVal;
if (v === true) pyVal = 'True'; else if (v === false) pyVal = 'False';
else if (v === null) pyVal = 'None'; else if (typeof v === 'string') pyVal = `"${v}"`;
else pyVal = String(v);
return `"${k}":${pyVal}`;
});
return '{' + parts.join(',') + '}';
}
function tmplModels(spec) {
const saTypes = new Set(['Integer']);
for (const e of spec.entities) for (const f of e.fields) saTypes.add(f.sa_type.match(/^(\w+)/)[1]);
const relMap = {};
for (const r of (spec.relationships || [])) {
const target = spec.entities.find(e => e.name === r.to);
if (target) relMap[`${r.from}.${r.field}`] = target.table_name;
}
if (Object.keys(relMap).length > 0) saTypes.add('ForeignKey');
const imports = [...saTypes].sort().join(', ');
let code = `from sqlalchemy import create_engine, Column, ${imports}\nfrom sqlalchemy.orm import declarative_base, sessionmaker\n\nDATABASE_URL = "sqlite:///./app.db"\nengine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})\nSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\nBase = declarative_base()\n\n`;
for (const e of spec.entities) {
code += `class ${e.name}(Base):\n __tablename__ = "${e.table_name}"\n id = Column(Integer, primary_key=True, index=True)\n`;
for (const f of e.fields) {
const fkTarget = relMap[`${e.name}.${f.name}`];
let parts = fkTarget ? [`Column(${f.sa_type}, ForeignKey("${fkTarget}.id")`] : [`Column(${f.sa_type}`];
if (!f.nullable) parts.push('nullable=False');
if (f.default !== null && f.default !== undefined) parts.push(`default=${pyLiteral(f.default)}`);
code += ` ${f.name} = ${parts.join(', ')})\n`;
}
code += '\n';
}
code += 'Base.metadata.create_all(bind=engine)\n';
return code;
}
function tmplSchemas(spec) {
const dtTypes = new Set();
for (const e of spec.entities) for (const f of e.fields) {
if (/\bdate\b/i.test(f.py_type) && !/datetime/.test(f.py_type)) dtTypes.add('date');
if (/\bdatetime\b/i.test(f.py_type)) dtTypes.add('datetime');
}
let code = 'from pydantic import BaseModel, ConfigDict\n';
if (dtTypes.size > 0) code += `from datetime import ${[...dtTypes].sort().join(', ')}\n`;
for (const imp of (spec.extra_imports || [])) {
if (/^(date|datetime)$/.test(imp.trim())) continue;
if (/^from\s/.test(imp) || /^import\s/.test(imp)) code += imp + '\n';
}
code += '\n';
for (const e of spec.entities) {
code += `class ${e.name}Create(BaseModel):\n`;
for (const f of e.fields) {
if (f.default !== null && f.default !== undefined) code += ` ${f.name}: ${f.py_type} = ${pyLiteral(f.default)}\n`;
else if (f.nullable && f.py_type.includes('None')) code += ` ${f.name}: ${f.py_type} = None\n`;
else code += ` ${f.name}: ${f.py_type}\n`;
}
code += `\nclass ${e.name}Response(${e.name}Create):\n id: int\n model_config = ConfigDict(from_attributes=True)\n\n`;
}
return code;
}
function tmplMain(spec) {
const modelNames = spec.entities.map(e => e.name).join(', ');
const createNames = spec.entities.map(e => e.name+'Create').join(', ');
const responseNames = spec.entities.map(e => e.name+'Response').join(', ');
let code = `from fastapi import FastAPI, Depends, HTTPException\nfrom sqlalchemy.orm import Session\nfrom models import Base, engine, SessionLocal, ${modelNames}\nfrom schemas import ${createNames}, ${responseNames}\n\napp = FastAPI()\n\ndef get_db():\n db = SessionLocal()\n try:\n yield db\n finally:\n db.close()\n\n`;
for (const e of spec.entities) {
const lo = e.name.toLowerCase(), tb = e.table_name;
code += `@app.post("/${tb}/", response_model=${e.name}Response, status_code=201)\ndef create_${lo}(item: ${e.name}Create, db: Session = Depends(get_db)):\n db_item = ${e.name}(**item.model_dump())\n db.add(db_item)\n db.commit()\n db.refresh(db_item)\n return db_item\n\n`;
code += `@app.get("/${tb}/", response_model=list[${e.name}Response])\ndef list_${lo}s(db: Session = Depends(get_db)):\n return db.query(${e.name}).all()\n\n`;
code += `@app.get("/${tb}/{item_id}", response_model=${e.name}Response)\ndef get_${lo}(item_id: int, db: Session = Depends(get_db)):\n item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n if not item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n return item\n\n`;
code += `@app.put("/${tb}/{item_id}", response_model=${e.name}Response)\ndef update_${lo}(item_id: int, item: ${e.name}Create, db: Session = Depends(get_db)):\n db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n if not db_item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n for key, value in item.model_dump().items():\n setattr(db_item, key, value)\n db.commit()\n db.refresh(db_item)\n return db_item\n\n`;
code += `@app.delete("/${tb}/{item_id}", status_code=204)\ndef delete_${lo}(item_id: int, db: Session = Depends(get_db)):\n db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n if not db_item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n db.delete(db_item)\n db.commit()\n\n`;
}
return code;
}
function tmplTests(spec) {
let code = `from fastapi.testclient import TestClient\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import sessionmaker\nfrom main import app, get_db\nfrom models import Base\n\nTEST_DB = "sqlite:///./test.db"\ntest_engine = create_engine(TEST_DB, connect_args={"check_same_thread": False})\nTestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)\nBase.metadata.create_all(bind=test_engine)\n\ndef override_get_db():\n db = TestSession()\n try:\n yield db\n finally:\n db.close()\n\napp.dependency_overrides[get_db] = override_get_db\nclient = TestClient(app)\n\n`;
for (const e of spec.entities) {
const lo = e.name.toLowerCase(), tb = e.table_name;
const testData = {};
for (const f of e.fields) {
if (f.default !== null && f.default !== undefined) { testData[f.name] = f.default; continue; }
if (f.py_type.includes('str')) testData[f.name] = `Test ${f.name}`;
else if (f.py_type.includes('int')) testData[f.name] = 1;
else if (f.py_type.includes('float')) testData[f.name] = 1.0;
else if (f.py_type.includes('bool')) testData[f.name] = true;
else if (f.py_type.includes('date')) testData[f.name] = '2024-01-15';
}
const td = pyJsonLiteral(testData);
const firstStr = e.fields.find(f => f.py_type.includes('str') && f.name !== 'status');
const updateData = {...testData};
if (firstStr) updateData[firstStr.name] = `Updated ${firstStr.name}`;
const ud = pyJsonLiteral(updateData);
code += `def test_create_${lo}():\n response = client.post('/${tb}/', json=${td})\n assert response.status_code == 201\n assert 'id' in response.json()\n\n`;
code += `def test_list_${lo}s():\n client.post('/${tb}/', json=${td})\n response = client.get('/${tb}/')\n assert response.status_code == 200\n assert len(response.json()) >= 1\n\n`;
code += `def test_get_${lo}_by_id():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.get(f'/${tb}/{item_id}')\n assert response.status_code == 200\n assert response.json()['id'] == item_id\n\n`;
code += `def test_get_${lo}_not_found():\n response = client.get('/${tb}/99999')\n assert response.status_code == 404\n\n`;
code += `def test_update_${lo}():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.put(f'/${tb}/{item_id}', json=${ud})\n assert response.status_code == 200\n\n`;
code += `def test_delete_${lo}():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.delete(f'/${tb}/{item_id}')\n assert response.status_code == 204\n response = client.get(f'/${tb}/{item_id}')\n assert response.status_code == 404\n\n`;
}
return code;
}
function tmplPyproject(spec) {
const name = (spec.project_name || 'app').toLowerCase().replace(/\s+/g, '-');
return `[project]\nname = "${name}"\nversion = "0.1.0"\nrequires-python = ">=3.11"\ndependencies = [\n "fastapi",\n "uvicorn[standard]",\n "sqlalchemy",\n "pytest",\n "httpx",\n]\n`;
}
// === Validaattori ===
function validateProjectCode(files) {
const issues = [];
for (const [fname, code] of Object.entries(files)) {
if (!fname.endsWith('.py')) continue;
const lines = code.split('\n');
for (const line of lines) {
const m = line.match(/^from\s+\.(\w*)\s+import/);
if (m) issues.push(`ISSUE: ${fname}: relatiivinen import`);
}
for (const line of lines) {
const m = line.match(/^from\s+(models|schemas|main)\s+import\s+(.+)/);
if (!m) continue;
const srcCode = files[m[1] + '.py'];
if (!srcCode) { issues.push(`ISSUE: ${fname}: ${m[1]}.py puuttuu`); continue; }
const names = m[2].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
for (const name of names) {
if (name && !srcCode.includes(name)) issues.push(`ISSUE: ${fname}: "${name}" puuttuu ${m[1]}.py:stä`);
}
}
if (fname === 'schemas.py') {
if (/:\s*date\b/.test(code) && !/from datetime import/.test(code))
issues.push('ISSUE: schemas.py: date-import puuttuu');
if (/:\s*datetime\b/.test(code) && !/from datetime import/.test(code))
issues.push('ISSUE: schemas.py: datetime-import puuttuu');
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
if (/(?<!["\w])false(?![\w"])/.test(line)) issues.push(`ISSUE: ${fname}:${i+1}: "false" → "False"`);
if (/(?<!["\w])true(?![\w"])/.test(line)) issues.push(`ISSUE: ${fname}:${i+1}: "true" → "True"`);
}
}
return issues;
}
function extractJson(text) {
const m = text.match(/```(?:json)?\s*\n([\s\S]*?)```/);
if (m) text = m[1].trim();
let depth = 0, start = null;
for (let i = 0; i < text.length; i++) {
if (text[i] === '{') { if (depth === 0) start = i; depth++; }
else if (text[i] === '}') { depth--; if (depth === 0 && start !== null) { try { return JSON.parse(text.slice(start, i+1)); } catch(e) { continue; } } }
}
return null;
}
// === Testiskenaariot ===
const SCENARIOS = [
{ id: 'todo', prompt: 'Todo-sovellus: tehtävien hallinta, deadline, prioriteetti ja status' },
{ id: 'users', prompt: 'REST API käyttäjähallinnalle SQLite-tietokannalla' },
{ id: 'blog', prompt: 'Blogi-API: kirjoittajat ja artikkelit, julkaisupäivämäärä ja status' },
];
// === Pipeline: yhdelle mallille ja skenaariolle ===
async function runPipeline(model, scenario) {
const result = {
model, scenario: scenario.id,
reqOk: false, specOk: false, specEntities: 0,
validationIssues: 0, fixRounds: 0,
testsTotal: 0, testsPassed: 0, testsFailed: 0,
totalDurationMs: 0, totalTokens: 0, avgTokPerSec: 0,
error: null,
};
const timings = [];
const dir = `${OUTPUT_DIR}/${model.replace(/[/:]/g, '_')}__${scenario.id}`;
mkdirSync(dir, { recursive: true });
try {
// 1. Vaatimukset
console.log(` [1/5] Vaatimukset...`);
const req = await ollamaChat(model, scenario.prompt, CLIENT_SYSTEM, 1024);
timings.push(req);
if (!req.text || req.text.length < 50) { result.error = 'Vaatimukset liian lyhyet'; return result; }
result.reqOk = true;
writeFileSync(`${dir}/_requirements.txt`, req.text);
// 2. JSON-speksi
console.log(` [2/5] JSON-speksi...`);
const specResp = await ollamaChat(model, `${req.text}\n\nOutput a JSON spec for this project.`, SPEC_SYSTEM, 2048);
timings.push(specResp);
const spec = extractJson(specResp.text);
if (!spec || !spec.entities || spec.entities.length === 0) { result.error = 'JSON-speksi epäonnistui'; writeFileSync(`${dir}/_spec_raw.txt`, specResp.text); return result; }
result.specOk = true;
result.specEntities = spec.entities.length;
writeFileSync(`${dir}/_spec.json`, JSON.stringify(spec, null, 2));
// 3. Template-generointi
console.log(` [3/5] Koodigenerointi...`);
const files = {
'models.py': tmplModels(spec),
'schemas.py': tmplSchemas(spec),
'main.py': tmplMain(spec),
'test_main.py': tmplTests(spec),
'pyproject.toml': tmplPyproject(spec),
};
// 4. Validointi + korjaussilmukka
let issues = validateProjectCode(files);
let fixRound = 0;
while (issues.length > 0 && fixRound < MAX_FIX_ROUNDS) {
fixRound++;
console.log(` [4/5] Korjauskierros ${fixRound} (${issues.length} ongelmaa)...`);
const issuesByFile = {};
for (const issue of issues) {
const m = issue.match(/^ISSUE:\s*(\S+?):/);
const fname = m ? m[1] : 'unknown';
if (!issuesByFile[fname]) issuesByFile[fname] = [];
issuesByFile[fname].push(issue);
}
for (const [fname, fIssues] of Object.entries(issuesByFile)) {
if (!files[fname]) continue;
const fixPrompt = `Fix the following issues in this Python file. Return ONLY the complete corrected file, no explanations.\n\nISSUES:\n${fIssues.join('\n')}\n\nCURRENT FILE (${fname}):\n\`\`\`python\n${files[fname]}\`\`\``;
const fixResp = await ollamaChat(model, fixPrompt, FIX_SYSTEM, 2048);
timings.push(fixResp);
if (fixResp.text) {
files[fname] = fixResp.text.replace(/^```(?:python)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim() + '\n';
}
}
issues = validateProjectCode(files);
}
result.validationIssues = issues.length;
result.fixRounds = fixRound;
// Kirjoita tiedostot levylle
for (const [fn, content] of Object.entries(files)) writeFileSync(`${dir}/${fn}`, content);
// 5. Pytest
console.log(` [5/5] Pytest...`);
try {
const uvPath = process.env.HOME + '/.local/bin/uv';
const uv = existsSync(uvPath) ? uvPath : 'uv';
execSync(`cd "${dir}" && ${uv} sync 2>/dev/null`, { timeout: 60000, stdio: 'pipe' });
execSync(`cd "${dir}" && rm -f app.db test.db`, { stdio: 'pipe' });
const pytestOut = execSync(`cd "${dir}" && ${uv} run pytest test_main.py -v --tb=short 2>&1`, { timeout: 60000, encoding: 'utf-8' });
writeFileSync(`${dir}/_pytest.txt`, pytestOut);
const passedMatch = pytestOut.match(/(\d+) passed/);
const failedMatch = pytestOut.match(/(\d+) failed/);
result.testsPassed = passedMatch ? parseInt(passedMatch[1]) : 0;
result.testsFailed = failedMatch ? parseInt(failedMatch[1]) : 0;
result.testsTotal = result.testsPassed + result.testsFailed;
} catch (e) {
const output = e.stdout || e.stderr || e.message || '';
writeFileSync(`${dir}/_pytest.txt`, output);
const passedMatch = output.match(/(\d+) passed/);
const failedMatch = output.match(/(\d+) failed/);
const errorMatch = output.match(/(\d+) error/);
result.testsPassed = passedMatch ? parseInt(passedMatch[1]) : 0;
result.testsFailed = (failedMatch ? parseInt(failedMatch[1]) : 0) + (errorMatch ? parseInt(errorMatch[1]) : 0);
result.testsTotal = result.testsPassed + result.testsFailed;
if (result.testsTotal === 0) result.error = 'Pytest kaatui';
}
} catch (e) {
result.error = e.message;
}
// Yhteenveto
result.totalDurationMs = timings.reduce((s, t) => s + t.durationMs, 0);
result.totalTokens = timings.reduce((s, t) => s + t.tokens, 0);
result.avgTokPerSec = timings.length > 0 ? timings.reduce((s, t) => s + t.tokPerSec, 0) / timings.length : 0;
return result;
}
// === Main ===
async function main() {
console.log('╔══════════════════════════════════════════════╗');
console.log('║ Kipinä Model Benchmark ║');
console.log('╚══════════════════════════════════════════════╝');
console.log(`Ollama: ${OLLAMA_URL}`);
// Haetaan mallit
let models;
try {
models = await ollamaListModels();
} catch (e) {
console.error(`Ei yhteyttä Ollamaan (${OLLAMA_URL}): ${e.message}`);
process.exit(1);
}
if (FILTER_MODELS) {
const filter = FILTER_MODELS.split(',').map(s => s.trim());
models = models.filter(m => filter.some(f => m.includes(f)));
}
console.log(`Mallit (${models.length}): ${models.join(', ')}`);
const scenarios = SCENARIO_FILTER === 'all' ? SCENARIOS : [SCENARIOS[0]];
console.log(`Skenaariot (${scenarios.length}): ${scenarios.map(s => s.id).join(', ')}`);
console.log(`Tulokset: ${OUTPUT_DIR}/`);
console.log('');
// Puhdista output
rmSync(OUTPUT_DIR, { recursive: true, force: true });
mkdirSync(OUTPUT_DIR, { recursive: true });
const results = [];
for (const model of models) {
for (const scenario of scenarios) {
console.log(`\n━━━ ${model} × ${scenario.id} ━━━`);
const r = await runPipeline(model, scenario);
results.push(r);
const status = r.error ? `${r.error}` :
r.testsPassed === r.testsTotal && r.testsTotal > 0 ? `${r.testsPassed}/${r.testsTotal}` :
`${r.testsPassed}/${r.testsTotal}`;
console.log(`${status} | ${(r.totalDurationMs/1000).toFixed(1)}s | ${r.totalTokens} tok | ${r.avgTokPerSec.toFixed(1)} tok/s`);
}
}
// === Tulostaulu ===
console.log('\n\n╔══════════════════════════════════════════════════════════════════════════════════════════════════╗');
console.log('║ TULOKSET ║');
console.log('╠══════════════════════════════════════════════════════════════════════════════════════════════════╣');
const header = [
'Malli'.padEnd(40),
'Skenaario'.padEnd(10),
'Speksi'.padEnd(8),
'Testit'.padEnd(10),
'Korjaus'.padEnd(8),
'Aika'.padEnd(8),
'tok/s'.padEnd(8),
'Tulos',
].join(' │ ');
console.log(`${header}`);
console.log('╠' + '═'.repeat(header.length + 2) + '╣');
for (const r of results) {
const specStatus = r.specOk ? `${r.specEntities}e` : '✗';
const testStatus = r.testsTotal > 0 ? `${r.testsPassed}/${r.testsTotal}` : '-';
const fixStatus = r.fixRounds > 0 ? `${r.fixRounds}×` : '-';
const time = `${(r.totalDurationMs/1000).toFixed(0)}s`;
const speed = `${r.avgTokPerSec.toFixed(0)}`;
const verdict = r.error ? '✗ FAIL' : r.testsPassed === r.testsTotal && r.testsTotal > 0 ? '✓ PASS' : '◐ PARTIAL';
const row = [
r.model.padEnd(40),
r.scenario.padEnd(10),
specStatus.padEnd(8),
testStatus.padEnd(10),
fixStatus.padEnd(8),
time.padEnd(8),
speed.padEnd(8),
verdict,
].join(' │ ');
console.log(`${row}`);
}
console.log('╚' + '═'.repeat(header.length + 2) + '╝');
// Tallenna JSON
writeFileSync(`${OUTPUT_DIR}/results.json`, JSON.stringify(results, null, 2));
console.log(`\nJSON: ${OUTPUT_DIR}/results.json`);
// Yhteenveto
const passed = results.filter(r => !r.error && r.testsPassed === r.testsTotal && r.testsTotal > 0);
const partial = results.filter(r => !r.error && r.testsPassed < r.testsTotal && r.testsTotal > 0);
const failed = results.filter(r => r.error || r.testsTotal === 0);
console.log(`\n✓ PASS: ${passed.length} | ◐ PARTIAL: ${partial.length} | ✗ FAIL: ${failed.length} | Yhteensä: ${results.length}`);
}
main().catch(e => { console.error(e); process.exit(1); });

Binary file not shown.

Binary file not shown.

83
projektit/projekti1.md Normal file
View File

@@ -0,0 +1,83 @@
---
title: UWB-paikannus sisätiloihin (2024)
tags: project
slideOptions:
text-align: left,
transition: slide,
theme: white,
hideAddressBar: true,
touch: true,
slideNumber: true,
controls: true,
controlsLayout: 'bottom-right'
spotlight:
enabled: true
---
# UWB-paikannus sisätiloihin (2026)
---
![](https://gitlab.dclabra.fi/wiki/uploads/upload_dbe5eca9539b590febdb95942eaa16a7.jpg)
----
Ennakkotietona annettakoon pisteiden kohdistaminen yllä olevaan kuvatiedostoon:
- y=0 yläreunan seinän sisäpinta, x=0 kassakoneiden keskilinja
- y=5220 alareunan seinän sisäpinta, x=10406 oikealla seinän sisäpinta
Paikassa n. 100, 2500 on yksi latausasema kärryille, siinä on sisäänkäynti kauppaan (turvaportit)
Paikassa n. 900, 3600 on varsinainen latausasema joka on alakerrassa. Sieltä on liukuportaat vasemmalle kuvassa. Siellä ei ole kunnollista paikannusta joka aiheuttaa paikan hyppimistä. Nämä latausasemat eivät ole kiinnostavia tietoja.
Eli yhden yksikön muutos koordinaateissa vastaa noin yhden senttimetrin muutosta "kartalla"?
---
# Dataformaatti
Datan formaatti on esitetty alla. Data itsessään on taltioituna csv-tiedostoihin. CSV-tiedostoja on paljon ja niissä on miljoonia rivejä, joten raakadatan käsittely voi olla raskasta.
![](https://gitlab.dclabra.fi/wiki/uploads/upload_b7a59c88b50fda806ec103ad2bbeeb6b.png)
Varsinaisen ETL-/ELT-prosessin jälkeen data pitäisi olla esikäsitelty ja siivottu. Tämä prosessi on kuitenkin syytä tehdä heti alkuun, jotta myöhemmät dataan liittyvät operaatiot olisivat nopeampia.
---
Tehtävälistaa:
- Data platform
- MariaDB-tietokantakontti
- Jupyterlab-kontti ETL-prosessia ja data-analyysia varten
- Visualisointi historiadatan perusteella
- Kärryjen liikkeet kaupan layoutissa
- Outlierit pois datasta (x,y-pisteet, jotka ylittävät rajat)
- Läpimenoaika, "kuumat alueet" (eli missä on vietetty aikaa)
- Tilastoja
- Datan ajallinen täsmällisyys (näytevälin dt keskiarvo ja keskihajonta)
- Datan paikannustäsmällisyys (outlayreiden esiintymistaajuus, paikannuksen kohina eli x,y-koordinaatin heittelehtiminen luonnottomasti)
- Raportteja päivä-, viikko-, kuukausitason "liikennöinnistä"
- Läpimenoaikojen tilastointi (eri aukioloajan tunteina, eri päivinä, ruuhkahuippujen / hiljaisimpien aikojen löytäminen)
- Kuinka monta kassaa on käytössä eri aukioloajan tunteina, eri päivinä
- Kassajonojen kertyminen (kuinka monta asiakasta jonottaa kuinka monessa jonossa)
- (x,y)-koordinaattien skaalaus mittayksikköön [m]
- Keskimääräinen kärryjen kulkema matka
- Kärryjen nopeus [km/h], nopeuden liukuva keskiarvoistus (valon nopeudella / mach-nopeuksilla tapahtuvien liikkeiden karsiminen pois)
- Ostoskärryjen tasainen kierto, onko kärryjä, jotka ovat erittäin paljon/vähän käytössä
- Visualisointeja ja tilastoja
- kuumat alueet visualisoituna pohjakuvaan
- eri aikaväleinä: 9.00-11.00; 11.00-13.00; 13.00-15.00; 15.00-17.00; 17.00-19.00; 19.00-21.00
- eri viikonpäivinä
- Tilastot ja kuvaajat yllä mainitusta (esim histogrammit)
- Useamman datalähteen yhdistäminen
- Esim. avoimen säätietohistorian yhdistäminen eri tuntien kävijämääriin
- Jotain muuta, asiakkaalle mahdollisesti lisäarvoa tuottavaa - keksikää jotain jännää!
---

Submodule projektit/projektiopinnot-1-datan-hallinta-ttm23sai added at c20e918b34