84 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
Jaakko Vanhala
ee048b0b68 kipina-node: automaattinen Ollama-instanssien haku + konttituki
Skripti skannaa localhost, 127.0.0.1, ollama, host.docker.internal
ja tarjoaa valikon jos useampi löytyy. Ei vaadi enää paikallista
ollama-binääriä — toimii myös Docker-konttia tai remote-instanssia
vasten. OLLAMA_URL välitetään Rust-binäärille.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:19:00 +03:00
Jaakko Vanhala
4e83569194 Konsoliloki näyttää mallin nimen: ✓ qwen2.5-coder:3b | 438 tok | 4952ms | 93.4 tok/s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:01:32 +03:00
Jaakko Vanhala
f42b692eeb Lyhennetty konsolilogi: yksi rivi per pyyntö + yksi rivi per tulos
Ennen: koko prompti + vastaus logitettiin (satoja rivejä)
Jälkeen:
  → task_id:abc | 42r prompti | "Write ONLY models.py..."
  ✓ 128 tok | 3200ms | 40.0 tok/s | "from sqlalchemy import..."

llm_done-viestissä prompt lyhennetty viimeiseen riviin (ei koko kontekstia).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:00:39 +03:00
Jaakko Vanhala
f79bb16f3d kipina-node binäärijakelu: download-skripti + macOS ARM64 binääri
kipina.studio/kipina-node — shell-skripti joka:
1. Tunnistaa OS/arch (macOS ARM, Linux x86/ARM)
2. Tarkistaa Ollaman (asennettu? käynnissä?)
3. Lataa kielimallin automaattisesti
4. Lataa oikean binäärin kipina.studio/download/
5. Käynnistää noden → yhdistää hubiin

Käyttö: curl -sSL https://kipina.studio/kipina-node | bash
Tai:    curl -sSL https://kipina.studio/kipina-node -o kipina-node && chmod +x kipina-node && ./kipina-node

build-binaries.sh — kääntää binäärit kaikille alustoille (Docker).
macOS ARM64 binääri (4.9MB) valmis, Linux x86_64 build käynnissä.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:51:31 +03:00
Jaakko Vanhala
e81fc33faf Join-dialogi: kaksi selkeää vaihetta (Ollama + kipina-node binääri)
Vaihe 1: Asenna Ollama
  curl -fsSL https://ollama.ai/install.sh | sh
  (+ brew/Windows-vaihtoehdot)

Vaihe 2: Lataa ja käynnistä kipina-node
  curl -sSL https://kipina.studio/kipina-node -o kipina-node && chmod +x kipina-node && ./kipina-node

Ei vaadi Rustia — valmis binääri ladataan suoraan.
Molemmat komennot kopioitavissa yhdellä klikkauksella.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:56:00 +03:00
Jaakko Vanhala
433726c553 Palautettu docker-compose.prod.yml: vain Caddy + Hub (ei Ollamaa palvelimella)
Ollama ajetaan käyttäjien omilla koneilla join.sh:n kautta,
ei palvelimella. Selain-Wasm toimii fallbackina.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:53:06 +03:00
Jaakko Vanhala
dec2e24e2f "Liitä koneesi" -nappi + join.sh + Docker native-node
UI: status-palkissa vihreä "+ Liitä koneesi" -nappi joka avaa dialogin:
  curl -sSL https://kipina.studio/join.sh | bash

join.sh:
- Tarkistaa Ollaman → tarjoaa asennusta jos puuttuu
- Käynnistää Ollaman jos ei pyöri
- Lataa kielimallin (qwen2.5-coder:3b)
- Käynnistää native-noden → yhdistää wss://kipina.studio/ws

Docker: Dockerfile.native + docker-compose.prod.yml päivitetty
ollama + native-node -konteilla palvelinpuolelle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:46:22 +03:00
Jaakko Vanhala
9058033669 Poistettu fonttiskaalaus (A-/A+) — ei vaikuttanut terminaaliin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:33:20 +03:00
Jaakko Vanhala
8bd86e6325 Fonttikoon A-/A+ säädin: ±20% viidessä askeleessa
Oikeassa yläkulmassa A- ja A+ napit. Skaalaa 80-120%, tallennetaan localStorageen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:31:33 +03:00
Jaakko Vanhala
c1133bb075 Terminaalin fontti 15→16px
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:27:31 +03:00
Jaakko Vanhala
6502d75efc Terminaalin syöttökenttä korostettu: sininen reunus, varjo, isompi fontti 16px
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:25:37 +03:00
Jaakko Vanhala
9f8b7fe920 UI-fonttikoot kasvatettu: body 16px, terminaali 15px, tabit 15px, status 14px
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:23:46 +03:00
Jaakko Vanhala
746bc20fcb Agenttikuvakkeet kasvatettu: 50→64px kuva, 72→90px kortti, isompi fontti
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:22:42 +03:00
Jaakko Vanhala
93f6baa0ea UI kasvatettu: container 1200→1600px, terminaali korkeampi, padding leveämpi
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:18:34 +03:00
Jaakko Vanhala
cc8e871735 deploy-fast.sh: luo hakemisto palvelimelle ennen rsync:iä
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:15:19 +03:00
Jaakko Vanhala
e90f3460c3 deploy-fast.sh: päivitä vain frontend ilman kontin uudelleenkäynnistystä
docker-compose.prod.yml: frontend/dist mountataan volumena (read-only).
Hub servaa tiedostot suoraan — rsync päivittää ne lennossa.

Kolme deploy-tasoa:
1. deploy-fast.sh — vain frontend (sekunteja, ei downtime)
2. deploy-light.sh — rsync + remote Docker build (minuutteja)
3. deploy.sh — lokaali build + image siirto (hidas mutta varma)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:12:10 +03:00
Jaakko Vanhala
4d74c38618 Dockerfile: poistettu turha COPY pkg (Astro kopioi public/:n automaattisesti)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:06:39 +03:00
Jaakko Vanhala
8a1b204179 v0.3.1: Avatarit WebP (18MB→256KB), PNG:t temp-kansioon
Kaikki avatar-viittaukset .png → .webp (200px, quality 80).
Alkuperäiset PNG:t siirretty temp/avatars-png/ (gitignored).
Hub-versio 0.3.0 → 0.3.1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:06:02 +03:00
Jaakko Vanhala
b19f5a3518 deploy-light.sh: rsync + remote build (ei image-siirtoa)
Lähettää vain lähdekoodin rsync:llä (~2MB muuttuneet tiedostot),
palvelin buildaa Docker-imagen itse. Nopeampi kuin 80MB imagen siirto.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:56:01 +03:00
108 changed files with 4294 additions and 1505 deletions

6
.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

@@ -0,0 +1,21 @@
# Native-node: Rust + Ollama-client (ei GPU-tunnistusta)
FROM rust:slim AS builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY native-node/Cargo.toml native-node/Cargo.toml
COPY native-node/src native-node/src
# Dummy-cratet workspace-yhteensopivuuteen
COPY hub/Cargo.toml hub/Cargo.toml
COPY node/Cargo.toml node/Cargo.toml
COPY cli/Cargo.toml cli/Cargo.toml
RUN mkdir -p hub/src node/src cli/src && touch hub/src/main.rs node/src/lib.rs cli/src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release -p native-node --no-default-features \
&& cp /app/target/release/native-node /usr/local/bin/native-node
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/bin/native-node /usr/local/bin/native-node
CMD ["native-node"]

View File

@@ -5,23 +5,27 @@ FROM node:22-slim AS frontend
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./ COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install --silent RUN npm install --silent
COPY frontend/ . # Cache-buster: git hash pakottaa rebuildin kun koodi muuttuu
COPY frontend/public/pkg public/pkg 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
@@ -32,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 \
@@ -51,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

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

@@ -19,6 +19,9 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- DATABASE_PATH=/data/nodes.db - DATABASE_PATH=/data/nodes.db
- STATIC_DIR=/app/frontend/dist
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
- NODE_API_KEY=${NODE_API_KEY:-}
volumes: volumes:
- hub_data:/data - hub_data:/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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1 @@
cf3bf54

Binary file not shown.

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

@@ -0,0 +1,73 @@
#!/bin/bash
# Kipinä — liitä koneesi laskentaverkkoon
set -e
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
MODEL="${KIPINA_MODEL:-qwen2.5-coder:3b}"
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ Kipinä Agentic Network — Node Join ║"
echo " ╚══════════════════════════════════════╝"
echo ""
# 1. Ollama
if command -v ollama &>/dev/null; then
echo " ✓ Ollama löytyi: $(ollama --version 2>/dev/null || echo 'asennettu')"
else
echo " Ollama ei ole asennettu."
echo ""
read -p " Asennetaanko Ollama? (k/e) " -n 1 -r; echo
if [[ $REPLY =~ ^[Kk]$ ]]; then
echo " Asennetaan Ollama..."
curl -fsSL https://ollama.ai/install.sh | sh
else
echo " Ollama vaaditaan laskentaan. Asenna: https://ollama.ai"
exit 1
fi
fi
# 2. Varmistetaan että Ollama on käynnissä
if ! curl -s http://localhost:11434/api/tags &>/dev/null; then
echo " Käynnistetään Ollama..."
ollama serve &>/dev/null &
sleep 3
if ! curl -s http://localhost:11434/api/tags &>/dev/null; then
echo " ✗ Ollama ei käynnistynyt. Aja: ollama serve"
exit 1
fi
fi
echo " ✓ Ollama käynnissä"
# 3. Malli
if ollama list 2>/dev/null | grep -q "$MODEL"; then
echo " ✓ Malli $MODEL ladattu"
else
echo " Ladataan malli $MODEL..."
ollama pull "$MODEL"
fi
# 4. Native-node
echo ""
echo " Yhdistetään hubiin: $HUB_URL"
echo " Malli: $MODEL"
echo " Ctrl+C pysäyttää"
echo ""
# Tarkistetaan onko native-node käännetty
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
NATIVE_BIN="$SCRIPT_DIR/target/release/native-node"
if [ -f "$NATIVE_BIN" ]; then
HUB_URL="$HUB_URL" OLLAMA_MODEL="$MODEL" "$NATIVE_BIN"
elif command -v cargo &>/dev/null && [ -f "$SCRIPT_DIR/native-node/Cargo.toml" ]; then
echo " Käännetään native-node..."
cd "$SCRIPT_DIR"
cargo build --release -p native-node --no-default-features 2>&1 | tail -1
HUB_URL="$HUB_URL" OLLAMA_MODEL="$MODEL" "$NATIVE_BIN"
else
echo " ✗ native-node binääriä ei löydy eikä Rust ole asennettu."
echo " Asenna Rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
echo " Tai lataa valmis binääri: https://kipina.studio/download"
exit 1
fi

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"

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

@@ -10,6 +10,39 @@
<span id="compute-dot" class="status-dot" style="background:#30363d"></span> <span id="compute-dot" class="status-dot" style="background:#30363d"></span>
<span style="color:#8b949e">Laskenta:</span> <span style="color:#8b949e">Laskenta:</span>
<span id="compute-label" style="color:#8b949e">—</span> <span id="compute-label" style="color:#8b949e">—</span>
<button id="compute-btn" class="btn btn-accent" title="Käynnistä kielimalli">Alusta</button> <button id="compute-btn" class="btn btn-accent" title="Käynnistä kielimalli selaimessa">Alusta</button>
</span>
<span class="status-separator">│</span>
<span class="status-group">
<button id="join-btn" class="btn btn-green" onclick="showJoinDialog()" title="Liitä oma koneesi laskentaverkkoon (natiivi, nopea)">+ Liitä koneesi</button>
</span> </span>
</div> </div>
<!-- Join-dialogi -->
<div id="join-dialog" style="display:none;margin-top:8px;padding:16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;font-size:14px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span style="color:#e6edf3;font-weight:600;font-size:16px">Liitä koneesi laskentaverkkoon</span>
<button onclick="document.getElementById('join-dialog').style.display='none'" style="background:none;border:none;color:#8b949e;cursor:pointer;font-size:18px">✕</button>
</div>
<p style="color:#8b949e;margin-bottom:16px">Koneesi suorittaa tehtäviä ~10-50x nopeammin kuin selainlaskenta. Kaksi vaihetta:</p>
<!-- Vaihe 1: Ollama -->
<div style="margin-bottom:14px;padding:12px;background:var(--bg);border-radius:4px;border-left:3px solid var(--accent)">
<div style="color:#e6edf3;font-weight:600;margin-bottom:6px">1. Asenna Ollama <span style="color:#8b949e;font-weight:normal">(kielimallimoottori)</span></div>
<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 -fsSL https://ollama.ai/install.sh | sh</code>
<button onclick="navigator.clipboard.writeText('curl -fsSL https://ollama.ai/install.sh | sh');this.textContent='✓';setTimeout(()=>this.textContent='Kopioi',1500)" class="btn btn-accent" style="padding:6px 10px">Kopioi</button>
</div>
<div style="color:#8b949e;font-size:12px">macOS: <code style="color:var(--accent)">brew install ollama</code> · Windows: <a href="https://ollama.ai/download" target="_blank" style="color:var(--accent)">ollama.ai/download</a> · Jos jo asennettu → siirry vaiheeseen 2.</div>
</div>
<!-- Vaihe 2: Kipinä-node -->
<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="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?v=$(date +%s)" -o kipina-node && chmod +x kipina-node && ./kipina-node</code>
<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 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,38 +9,100 @@
--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; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
} }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; } .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: 8px 16px; 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;
background: var(--bg); color: #8b949e; font-size: 14px; background: var(--bg); color: #8b949e; font-size: 15px;
} }
.tab.active { background: var(--panel); color: var(--accent); border-color: var(--border); } .tab.active { background: var(--panel); color: var(--accent); border-color: var(--border); }
/* 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 {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 8px 14px; background: var(--bg); padding: 10px 16px; background: var(--bg);
border: 1px solid var(--border); border-radius: 6px 6px 0 0; border: 1px solid var(--border); border-radius: 6px 6px 0 0;
font-family: 'Courier New', monospace; font-size: 13px; font-family: 'Courier New', monospace; font-size: 14px;
} }
.status-dot { .status-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block; width: 8px; height: 8px; border-radius: 50%; display: inline-block;
@@ -50,21 +113,22 @@ body {
/* Terminal */ /* Terminal */
.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: 14px; font-family: 'Courier New', monospace; font-size: 16px;
min-height: 300px; max-height: 60vh; overflow-y: auto; flex: 1; min-height: 0; max-height: none; overflow-y: auto;
padding: 8px 12px; 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; }
.terminal-prompt { color: var(--yellow); margin-right: 8px; } .terminal-prompt { color: var(--yellow); margin-right: 8px; }
.terminal-input-row { .terminal-input-row {
display: flex; align-items: center; position: relative; display: flex; align-items: center; position: relative;
background: #010409; border: 1px solid var(--border); border-top: none; background: #0d1117; border: 1px solid var(--accent); border-top: none;
border-radius: 0 0 6px 6px; padding: 8px 12px; border-radius: 0 0 6px 6px; padding: 10px 14px;
font-family: 'Courier New', monospace; font-size: 14px; font-family: 'Courier New', monospace; font-size: 15px;
box-shadow: 0 2px 8px rgba(88,166,255,0.1);
} }
.terminal-input { .terminal-input {
flex: 1; background: transparent; border: none; outline: none; flex: 1; background: transparent; border: none; outline: none;
color: var(--green); font-family: inherit; font-size: inherit; color: var(--green); font-family: inherit; font-size: 16px;
} }
.terminal-dropdown { .terminal-dropdown {
display: none; position: absolute; bottom: 100%; left: 30px; display: none; position: absolute; bottom: 100%; left: 30px;
@@ -79,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);
@@ -100,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; }
@@ -128,10 +199,10 @@ body {
background: linear-gradient(145deg, rgba(33,38,45,0.4) 0%, rgba(13,17,23,0.8) 100%); background: linear-gradient(145deg, rgba(33,38,45,0.4) 0%, rgba(13,17,23,0.8) 100%);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(240,246,252,0.1); border: 1px solid rgba(240,246,252,0.1);
border-radius: 12px; border-radius: 14px;
padding: 6px 6px 4px; padding: 8px 8px 6px;
text-align: center; text-align: center;
width: 72px; width: 90px;
opacity: 0.8; opacity: 0.8;
cursor: pointer; cursor: pointer;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
@@ -144,12 +215,12 @@ body {
box-shadow: 0 8px 14px rgba(0,0,0,0.4); box-shadow: 0 8px 14px rgba(0,0,0,0.4);
} }
.agent-avatar img { .agent-avatar img {
width: 50px; height: 50px; border-radius: 12px; width: 64px; height: 64px; border-radius: 14px;
margin-bottom: 4px; border: 2px solid rgba(240,246,252,0.1); margin-bottom: 4px; border: 2px solid rgba(240,246,252,0.1);
transition: all 0.4s ease; object-fit: cover; transition: all 0.4s ease; object-fit: cover;
} }
.agent-avatar .avatar-name { .agent-avatar .avatar-name {
font-size: 10px; color: #8b949e; white-space: nowrap; font-size: 11px; color: #8b949e; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; overflow: hidden; text-overflow: ellipsis;
} }
.agent-avatar.active { .agent-avatar.active {
@@ -163,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 */
@@ -193,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.0" 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);
@@ -931,40 +1093,46 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
} }
} 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,
}); }),
let _ = state.stats_tx.send(msg.to_string()); _ => serde_json::json!({
}
_ => {
// 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", "type": "llm_prompt",
"prompt": text, "prompt": text,
"model": task_type, "model": task_type,
}); }),
let _ = state.stats_tx.send(prompt.to_string()); };
} // Lähetetään takaisin lähettäjäsolmulle (käyttäjä haluaa oman tekstinsä tuloksen)
if let Some(tx) = state.node_channels.read().await.get(&node_id) {
let _ = tx.send(msg.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 any = if want_heavy {
// Heavy: priorisoi solmu jolla on suurin malli (B-parametrit)
let mut ranked: Vec<(u64, u32)> = matching.iter().map(|id| {
(*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| { let native = matching.iter().find(|id| {
node_types.get(id).map(|t| t == "native").unwrap_or(false) node_types.get(id).map(|t| t == "native").unwrap_or(false)
}).copied(); }).copied();
let any = native.or_else(|| matching.first().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 { match timeout {
Ok(msg) => msg, Ok(Ok(v)) => {
Err(broadcast::error::RecvError::Lagged(n)) => { if v["type"].as_str() == Some("llm_error") {
tracing::debug!("API-kanava lagged {} viestiä", n); let err = v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string();
continue; (axum::http::StatusCode::CONFLICT, err).into_response()
} } else {
Err(_) => return Ok(None), // Kanava suljettu axum::Json(ChatCompletionResponse {
};
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(), response: v["response"].as_str().unwrap_or("").to_string(),
model: v["model"].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), tokens_generated: v["tokens_generated"].as_u64().unwrap_or(0),
})); }).into_response()
} }
} }
} else if v["type"].as_str() == Some("llm_error") { Ok(Err(_)) => {
if let Some(tid) = v["task_id"].as_str() { // Oneshot-kanava sulkeutui (solmu katosi kesken laskennan)
if tid == payload.task_id { state.pending_responses.lock().unwrap().remove(&payload.task_id);
return Err(v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string()); 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()
} }
} }
} }
}
}
#[allow(unreachable_code)]
Ok(None)
}).await;
match timeout {
Ok(Ok(Some(res))) => axum::Json(res).into_response(),
Ok(Ok(None)) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response(),
Ok(Err(err)) => (axum::http::StatusCode::CONFLICT, err).into_response(),
Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response(),
}
}

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()
@@ -283,6 +304,18 @@ async fn main() {
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() {
#[cfg(not(feature = "gpu-detect"))] #[cfg(not(feature = "gpu-detect"))]
@@ -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,14 +437,140 @@ 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 { {
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("Network", "Yhdistetty hubiin".to_string(), None);
}
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 {
match engine.fetch_models().await {
Ok(tags) => {
let models: Vec<String> = tags.get("models")
.and_then(|v| v.as_array())
.map(|arr| arr.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
.collect())
.unwrap_or_default();
let mut st = tui_state.write().await;
st.model_picker_items = models;
st.model_picker_idx = 0;
st.model_picker_open = true;
}
Err(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);
}
}
}
}
}
}
ws_msg = read.next() => {
match ws_msg {
Some(Ok(Message::Text(text))) => {
// Hubin control-viestit
if text.contains(r#""type":"control""#) {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(action) = task.get("action").and_then(|v| v.as_str()) {
if action == "pause" {
tracing::info!("Hub pakotti solmun tauolle (Pause)");
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 // LLM-promptit
if text.contains("llm_prompt") { if text.contains("llm_prompt") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) { if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
@@ -343,31 +578,56 @@ async fn main() {
let task_id = task.get("task_id").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(""); 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")) { 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 { if let Some(ref engine) = llm {
let max_tokens = task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(1024) as usize; let gen_opts = inference::GenerateOptions {
tracing::info!("Generoidaan (task_id: {}, max_tokens: {}): \"{}\"", task_id, max_tokens, &prompt[..prompt.len().min(100)]); 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(); let model_name = engine.model_name();
match engine.generate(prompt, max_tokens).await { match engine.generate(prompt, &gen_opts).await {
Ok(result) => { Ok(result) => {
let tokens_sec = (result.tokens_per_sec * 10.0).round() / 10.0;
tracing::info!( tracing::info!(
"Tulos: {} tokenia | {:.0}ms | {:.1} tok/s | \"{}\"", "✓ {} | {} tok | {:.0}ms | {:.1} tok/s",
model_name,
result.tokens_generated, result.tokens_generated,
result.duration_ms, result.duration_ms,
result.tokens_per_sec, tokens_sec,
&result.text[..result.text.len().min(80)]
); );
{
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!({ let done = json!({
"type": "llm_done", "type": "llm_done",
"prompt": prompt, "prompt": prompt_short,
"model": format!("{} (Ollama)", model_name), "model": format!("{} (Ollama)", model_name),
"response": result.text, "response": result.text,
"tokens_generated": result.tokens_generated, "tokens_generated": result.tokens_generated,
"duration_ms": result.duration_ms, "duration_ms": result.duration_ms,
"tokens_per_sec": (result.tokens_per_sec * 10.0).round() / 10.0, "tokens_per_sec": tokens_sec,
"load_time_ms": 0, "load_time_ms": 0,
"task_id": task_id, "task_id": task_id,
}); });
@@ -375,12 +635,19 @@ async fn main() {
} }
Err(e) => { Err(e) => {
tracing::error!("Inferenssivirhe: {}", 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 // Mallin vaihto lennossa
if text.contains("change_model") { if text.contains("change_model") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) { if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
@@ -389,7 +656,12 @@ async fn main() {
tracing::info!("Vaihdetaan malli: {}", new_model); tracing::info!("Vaihdetaan malli: {}", new_model);
engine.set_model(new_model.to_string()); engine.set_model(new_model.to_string());
match engine.ensure_model().await { match engine.ensure_model().await {
Ok(()) => tracing::info!("Malli {} valmis!", new_model), 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), Err(e) => tracing::error!("Mallin lataus epäonnistui: {}", e),
} }
} }
@@ -397,10 +669,34 @@ async fn main() {
} }
} }
} }
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
} }

Some files were not shown because too many files have changed in this diff Show More