30 Commits

Author SHA1 Message Date
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
45 changed files with 1573 additions and 1367 deletions

3
.gitignore vendored
View File

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

131
kipina-node Executable file
View File

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

BIN
kipina-node-bin Executable file

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -1,70 +0,0 @@
#!/bin/bash
set -e
if [ "$1" == "local" ]; then
echo "=== Kipinä Studio Local Development ==="
echo "Käynnistetään kokonaisuus puhtaasti Docker-kontissa..."
docker compose up agentic-poc
exit 0
fi
SERVER="ubuntu@86.50.252.98"
REMOTE_DIR="~/code/agentic-studio/network-poc"
KEY="$HOME/.ssh/id_rsa"
SSH_OPTS="-o StrictHostKeyChecking=no -i $KEY"
# Varmistetaan, että SSH-avain on agentissa
if ! ssh-add -l 2>/dev/null | grep -q id_rsa; then
echo "SSH-avain ei ole agentissa. Lisätään..."
ssh-add "$KEY"
fi
echo "=== Kipinä Studio Deploy ==="
# 0. Commitoidaan uncommitted muutokset ennen deployta
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null || \
[ -n "$(git -C "$SCRIPT_DIR" ls-files --others --exclude-standard 2>/dev/null)" ]; then
echo "[0] Uncommitted muutoksia havaittu — commitoidaan..."
read -rp " Commit-viesti: " DEPLOY_MSG
if [ -z "$DEPLOY_MSG" ]; then
DEPLOY_MSG="Deploy $(date +%Y-%m-%d\ %H:%M)"
fi
git -C "$SCRIPT_DIR" add -A
git -C "$SCRIPT_DIR" commit -m "$DEPLOY_MSG"
echo " Commitoitu: $DEPLOY_MSG"
fi
# 1. Rakennetaan Docker-image lokaalisti
echo "[1/4] Rakennetaan image lokaalisti..."
docker build --platform linux/amd64 -f Dockerfile.prod -t kipina-agentic:latest .
# 2. Tallennetaan tiedostoon
echo "[2/5] Pakataan image..."
docker save kipina-agentic:latest | gzip > /tmp/kipina-agentic.tar.gz
echo " Koko: $(du -h /tmp/kipina-agentic.tar.gz | cut -f1)"
# 3. Siirretään palvelimelle
echo "[3/5] Siirretään palvelimelle..."
scp $SSH_OPTS /tmp/kipina-agentic.tar.gz $SERVER:/tmp/
scp $SSH_OPTS docker-compose.prod.yml Caddyfile.prod $SERVER:$REMOTE_DIR/
# 4. Ladataan image ja käynnistetään
echo "[4/5] Ladataan image palvelimella..."
ssh $SSH_OPTS $SERVER "gunzip -c /tmp/kipina-agentic.tar.gz | docker load && rm /tmp/kipina-agentic.tar.gz"
echo "[5/5] Käynnistetään palvelut uudelleen..."
ssh $SSH_OPTS $SERVER "cd $REMOTE_DIR && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d"
echo "=== Valmis! https://kipina.studio ==="
# Discord-notifikaatio
DISCORD_WEBHOOK="https://discord.com/api/webhooks/1489504066898755687/8U02d0wug-3MkVax0xMmRoj0s_-V1psnNLPWdSOjnGnKRBUpPjaU6XiX9Iu8DgJI69AP"
COMMIT_HASH=$(git -C "$SCRIPT_DIR" log -1 --pretty=format:"%h" 2>/dev/null || echo "?")
COMMIT_MSG=$(git -C "$SCRIPT_DIR" log -1 --pretty=format:"%s" 2>/dev/null || echo "?")
# python3 escapettaa erikoismerkit JSON-turvallisesti
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'content': sys.argv[1]}))" \
"🚀 **Kipinä Studio julkaistu!**
> \`${COMMIT_HASH}\` ${COMMIT_MSG}
> https://kipina.studio")
curl -s -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK" > /dev/null

View File

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

View File

@@ -0,0 +1 @@
dirty-3e9cdd70c60dadfb970cee47ebbd912c

Binary file not shown.

View File

@@ -4,7 +4,6 @@ set -e
BASE_URL="https://kipina.studio/download" BASE_URL="https://kipina.studio/download"
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}" HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
MODEL="${KIPINA_MODEL:-qwen2.5-coder:3b}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}" OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
# Tunnista OS ja arkkitehtuuri # Tunnista OS ja arkkitehtuuri
@@ -96,26 +95,37 @@ fi
echo "" echo ""
echo " Hub: $HUB_URL" echo " Hub: $HUB_URL"
echo " Ollama: $OLLAMA_URL" echo " Ollama: $OLLAMA_URL"
echo " Malli: $MODEL" if [ -n "$KIPINA_MODEL" ]; then
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
# Lataa malli (toimii sekä lokaalilla binäärillä että API:n kautta)
if ! curl -s "$OLLAMA_URL/api/tags" | grep -q "$MODEL"; then
echo " Ladataan $MODEL..."
curl -s "$OLLAMA_URL/api/pull" -d "{\"name\":\"$MODEL\"}" > /dev/null
fi fi
echo " ✓ Malli $MODEL valmis"
# Lataa binääri # Lataa binääri
BIN_PATH="./kipina-node-bin" 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 if [ ! -f "$BIN_PATH" ]; then
echo " Ladataan $BINARY..." echo " Ladataan tuorein $BINARY..."
curl -sSL "$BASE_URL/$BINARY" -o "$BIN_PATH" curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
chmod +x "$BIN_PATH" chmod +x "$BIN_PATH"
fi fi
echo "" echo ""
echo " ✓ Yhdistetään laskentaverkkoon..." echo " ✓ Siirrytään Kipinä Noden hallintaan..."
echo " Ctrl+C pysäyttää" echo " Ctrl+C pysäyttää"
echo "" echo ""
HUB_URL="$HUB_URL" OLLAMA_URL="$OLLAMA_URL" OLLAMA_MODEL="$MODEL" exec "$BIN_PATH" if [ -n "$KIPINA_MODEL" ]; then
export OLLAMA_MODEL="$KIPINA_MODEL"
fi
export HUB_URL="$HUB_URL"
export OLLAMA_URL="$OLLAMA_URL"
exec "$BIN_PATH"

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

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

View File

@@ -71,7 +71,25 @@ import Settings from "../components/Settings.astro";
// === Globaalit tilat === // === Globaalit tilat ===
const defaultAgents = { const defaultAgents = {
manager: { name: 'Manageri', avatar: '/avatars/karhunpentu.webp', model: 'qwen-coder', order: 0, client: { name: 'Asiakas', avatar: '/avatars/kettu_notext.webp', model: 'qwen-coder', order: 0,
temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are a product owner who turns vague ideas into clear, actionable software requirements.
GIVEN a short project description from the user, produce a structured brief:
1. PROJECT NAME: a short, descriptive name
2. GOAL: one sentence explaining what the software does and who it's for
3. CORE FEATURES: numbered list of 3-5 concrete features (not vague wishes)
4. DATA MODEL: list the main entities and their key fields
5. API ENDPOINTS: list the essential REST endpoints (method + path + purpose)
6. CONSTRAINTS: any technical constraints (e.g. "must use SQLite", "no auth needed for MVP")
RULES:
- Be specific: "User can filter todos by status" not "todo management"
- Keep scope small — MVP only, no nice-to-haves
- Use plain English, no code
- Maximum 200 words total` },
manager: { name: 'Manageri', avatar: '/avatars/karhunpentu.webp', model: 'qwen-coder', order: 1,
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 512, temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are a senior project manager and software architect. Your job is to plan the file structure of a software project. prompt: `You are a senior project manager and software architect. Your job is to plan the file structure of a software project.
@@ -88,7 +106,7 @@ models.py: SQLAlchemy database models and engine setup
schemas.py: Pydantic request/response schemas schemas.py: Pydantic request/response schemas
main.py: FastAPI application with CRUD endpoints main.py: FastAPI application with CRUD endpoints
pyproject.toml: project dependencies` }, pyproject.toml: project dependencies` },
coder: { name: 'Koodari', avatar: '/avatars/kipina_notext.webp', model: 'qwen-coder', order: 1, coder: { name: 'Koodari', avatar: '/avatars/kipina_notext.webp', model: 'qwen-coder', order: 2,
temperature: 0.7, topK: 40, repeatPenalty: 1.15, maxTokens: 1024, temperature: 0.7, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are an expert Python developer. Write complete, production-ready code. prompt: `You are an expert Python developer. Write complete, production-ready code.
@@ -109,7 +127,7 @@ NEVER:
- Forget to import from other project files - Forget to import from other project files
- Use requirements.txt or Poetry — always use pyproject.toml with [project] format (PEP 621) - Use requirements.txt or Poetry — always use pyproject.toml with [project] format (PEP 621)
- Use pip install — use uv (e.g. uv run uvicorn main:app --reload)` }, - Use pip install — use uv (e.g. uv run uvicorn main:app --reload)` },
data: { name: 'Data', avatar: '/avatars/pesukarhu_notext.webp', model: 'qwen-coder', order: 2, data: { name: 'Data', avatar: '/avatars/pesukarhu_notext.webp', model: 'qwen-coder', order: 3,
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 1024, temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are a database architect specializing in SQLAlchemy and relational databases. prompt: `You are a database architect specializing in SQLAlchemy and relational databases.
@@ -126,7 +144,7 @@ ALWAYS INCLUDE:
- from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy.ext.declarative import declarative_base
- from sqlalchemy.orm import sessionmaker - from sqlalchemy.orm import sessionmaker
- DATABASE_URL, engine, SessionLocal, Base` }, - DATABASE_URL, engine, SessionLocal, Base` },
qa: { name: 'QA', avatar: '/avatars/susi_notext.webp', model: 'qwen-coder', order: 3, qa: { name: 'QA', avatar: '/avatars/susi_notext.webp', model: 'qwen-coder', order: 4,
temperature: 0.4, topK: 40, repeatPenalty: 1.15, maxTokens: 1024, temperature: 0.4, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are a QA engineer writing automated tests. prompt: `You are a QA engineer writing automated tests.
@@ -143,7 +161,7 @@ TEST STRUCTURE:
5. test_delete: DELETE → 204, verify GET returns 404 after 5. test_delete: DELETE → 204, verify GET returns 404 after
ALWAYS: from fastapi.testclient import TestClient` }, ALWAYS: from fastapi.testclient import TestClient` },
tester: { name: 'DevOps', avatar: '/avatars/laiskiainen_notext.webp', model: 'qwen-coder', order: 4, tester: { name: 'DevOps', avatar: '/avatars/laiskiainen_notext.webp', model: 'qwen-coder', order: 5,
temperature: 0.3, topK: 40, repeatPenalty: 1.1, maxTokens: 512, temperature: 0.3, topK: 40, repeatPenalty: 1.1, maxTokens: 512,
prompt: `You are a strict code reviewer and static analysis expert. Analyze the code line by line. prompt: `You are a strict code reviewer and static analysis expert. Analyze the code line by line.
@@ -162,7 +180,7 @@ RESPOND:
- If all checks pass: "LGTM" - If all checks pass: "LGTM"
- If issues found: list each as "ISSUE: filename.py: description" - If issues found: list each as "ISSUE: filename.py: description"
- Be specific and actionable, not vague` }, - Be specific and actionable, not vague` },
observer: { name: 'Tarkkailija', avatar: '/avatars/aikuinen_susi.webp', model: 'qwen-coder', order: 5, observer: { name: 'Tarkkailija', avatar: '/avatars/aikuinen_susi.webp', model: 'qwen-coder', order: 6,
temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 512, temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are an independent technical observer and risk analyst. prompt: `You are an independent technical observer and risk analyst.
@@ -178,7 +196,7 @@ OUTPUT FORMAT:
- End with overall assessment: "SHIP IT" or "NEEDS WORK: reason"` }, - End with overall assessment: "SHIP IT" or "NEEDS WORK: reason"` },
}; };
// Versio: kasvata kun oletuspromptit muuttuvat // Versio: kasvata kun oletuspromptit muuttuvat
const AGENTS_VERSION = 2; const AGENTS_VERSION = 3;
let agents; let agents;
const savedVersion = parseInt(localStorage.getItem('kpn-agents-version') || '0'); const savedVersion = parseInt(localStorage.getItem('kpn-agents-version') || '0');
if (savedVersion < AGENTS_VERSION && localStorage.getItem('kpn-agents')) { if (savedVersion < AGENTS_VERSION && localStorage.getItem('kpn-agents')) {
@@ -483,10 +501,16 @@ OUTPUT FORMAT:
// Wasm-autostart vain jos natiivisolmua ei löydy (tarkistetaan onopen:ssa) // Wasm-autostart vain jos natiivisolmua ei löydy (tarkistetaan onopen:ssa)
// === Pipeline-keskeytys ===
let pipelineAbort = null; // AbortController tai null
// === kpnRun: lähettää promptin mallille === // === kpnRun: lähettää promptin mallille ===
const activeStreams = {}; const activeStreams = {};
async function kpnRun(model, prompt, silent) { async function kpnRun(model, prompt, silent, agentOpts) {
// Tarkistetaan keskeytys
if (pipelineAbort?.signal?.aborted) return null;
const taskId = crypto.randomUUID(); const taskId = crypto.randomUUID();
const statusDiv = document.createElement('div'); const statusDiv = document.createElement('div');
statusDiv.className = 'terminal-line'; statusDiv.className = 'terminal-line';
@@ -496,10 +520,6 @@ OUTPUT FORMAT:
termPanel.scrollTop = termPanel.scrollHeight; termPanel.scrollTop = termPanel.scrollHeight;
try { try {
// Ei odotetaan Wasmia — lähetetään suoraan hubille.
// Jos hub löytää natiivisolmun, vastaus tulee nopeasti.
// Jos 503, käynnistetään Wasm-fallback.
if (!silent) { if (!silent) {
const streamDiv = document.createElement('div'); const streamDiv = document.createElement('div');
streamDiv.className = 'terminal-line'; streamDiv.className = 'terminal-line';
@@ -511,10 +531,24 @@ OUTPUT FORMAT:
statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:var(--accent)">${model}</span> käsittelee...</span>`; statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:var(--accent)">${model}</span> käsittelee...</span>`;
// Rakennetaan pyyntö: agentin asetukset tai globaalit oletukset
const opts = agentOpts || {};
const payload = {
model,
prompt,
task_id: taskId,
system_prompt: opts.prompt || settings.systemPrompt || undefined,
temperature: opts.temperature ?? settings.temperature ?? undefined,
top_k: opts.topK ?? settings.topK ?? undefined,
max_tokens: opts.maxTokens ?? settings.maxTokens ?? undefined,
repeat_penalty: opts.repeatPenalty ?? settings.repeatPenalty ?? undefined,
};
const res = await fetch('/api/v1/chat/completions', { const res = await fetch('/api/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, task_id: taskId }), body: JSON.stringify(payload),
signal: pipelineAbort?.signal,
}); });
if (res.status === 503 && !wasmNodeStarted) { if (res.status === 503 && !wasmNodeStarted) {
@@ -578,8 +612,8 @@ OUTPUT FORMAT:
// === Terminal commands === // === Terminal commands ===
const kpnCommands = { const kpnCommands = {
'kpn': ['help','run','project','pipeline','load','status','models','clear'], 'kpn': ['help','run','project','pipeline','stop','load','status','models','clear'],
'kpn run': ['coder','coder-3b','manager','tester','qa','qwen-coder','smollm-135m'], 'kpn run': ['coder','coder-3b','manager','tester','qa','qwen-coder'],
'kpn load': ['1','2'], 'kpn load': ['1','2'],
'kpn project': ['"'], 'kpn project': ['"'],
'kpn pipeline': ['"'], 'kpn pipeline': ['"'],
@@ -587,7 +621,7 @@ OUTPUT FORMAT:
const kpnExamples = { const kpnExamples = {
'kpn run coder': ['"hello world in python"','"fibonacci in rust"','"quicksort in javascript"'], 'kpn run coder': ['"hello world in python"','"fibonacci in rust"','"quicksort in javascript"'],
'kpn run coder-3b': ['"REST API with Flask"','"binary search tree"'], 'kpn run coder-3b': ['"REST API with Flask"','"binary search tree"'],
'kpn project': ['"FastAPI + SQLite REST API"','"CLI tool for CSV processing"'], 'kpn project': ['"FastAPI + SQLite REST API"','"UWB indoor positioning analytics — CSV cart tracking data, heatmaps, statistics, Docker (MariaDB + Jupyter)"','"CLI tool for CSV processing"'],
'kpn pipeline': ['"todo-sovellus"','"laskin pythonilla"'], 'kpn pipeline': ['"todo-sovellus"','"laskin pythonilla"'],
}; };
@@ -648,20 +682,29 @@ OUTPUT FORMAT:
if (sub === 'help' || !sub) { if (sub === 'help' || !sub) {
termLog(' kpn run &lt;malli&gt; "prompti" — aja tehtävä', '#a5d6ff'); termLog(' kpn run &lt;malli&gt; "prompti" — aja tehtävä', '#a5d6ff');
termLog(' kpn project "kuvaus" — monivaiheinen projekti', '#a5d6ff'); termLog(' kpn project "kuvaus" — monivaiheinen projekti', '#a5d6ff');
termLog(' kpn pipeline "tehtävä" — nopea: manageri→koodari→testaaja', '#a5d6ff'); termLog(' kpn pipeline "tehtävä" — nopea: asiakas→manageri→koodari→testaaja', '#a5d6ff');
termLog(' kpn stop — keskeytä pipeline', '#a5d6ff');
termLog(' kpn load — lataa kielimalli', '#a5d6ff'); termLog(' kpn load — lataa kielimalli', '#a5d6ff');
termLog(' kpn models — mallit', '#a5d6ff'); termLog(' kpn models — mallit', '#a5d6ff');
termLog(' kpn status — verkon tila', '#a5d6ff'); termLog(' kpn status — verkon tila', '#a5d6ff');
termLog(' kpn clear — tyhjennä', '#a5d6ff'); termLog(' kpn clear — tyhjennä', '#a5d6ff');
} else if (sub === 'stop') {
if (pipelineAbort) {
pipelineAbort.abort();
pipelineAbort = null;
termLog(' ✋ Pipeline keskeytetty', '#d29922');
highlightAgent(null);
} else {
termLog(' Ei käynnissä olevaa pipelinea', '#8b949e');
}
} else if (sub === 'clear') { termPanel.innerHTML = ''; } else if (sub === 'clear') { termPanel.innerHTML = '';
} else if (sub === 'load') { } else if (sub === 'load') {
const btn = document.getElementById('compute-btn'); const btn = document.getElementById('compute-btn');
if (btn && btn.textContent.includes('Valmis')) { termLog(' ✓ Malli jo ladattu', '#3fb950'); } if (btn && btn.textContent.includes('Valmis')) { termLog(' ✓ Malli jo ladattu', '#3fb950'); }
else { btn?.click(); } else { btn?.click(); }
} else if (sub === 'models') { } else if (sub === 'models') {
termLog(' <span style="color:var(--accent)">1</span> qwen-coder Qwen2.5-Coder:0.5B <span style="color:#8b949e">~990 MB</span>'); termLog(' <span style="color:var(--accent)">1</span> qwen-coder Qwen2.5-Coder:0.5B <span style="color:#8b949e">~990 MB (selain)</span>');
termLog(' <span style="color:var(--accent)">2</span> qwen-coder-3b Qwen2.5-Coder:3B <span style="color:#8b949e">~6.2 GB</span>'); termLog(' <span style="color:var(--accent)">2</span> qwen-coder-3b Qwen2.5-Coder:3B <span style="color:#8b949e">~6.2 GB (Ollama)</span>');
termLog(' <span style="color:var(--accent)">3</span> smollm-135m SmolLM 135M <span style="color:#8b949e">~270 MB</span>');
} else if (sub === 'status') { } else if (sub === 'status') {
termLog(` Hub: ${document.getElementById('hub-label').textContent} | Laskenta: ${document.getElementById('compute-label').textContent}`, '#a5d6ff'); termLog(` Hub: ${document.getElementById('hub-label').textContent} | Laskenta: ${document.getElementById('compute-label').textContent}`, '#a5d6ff');
} else if (sub === 'run') { } else if (sub === 'run') {
@@ -721,62 +764,116 @@ OUTPUT FORMAT:
// === Template-pohjainen projektipipeline === // === Template-pohjainen projektipipeline ===
let templates = {}; let templates = {};
const TEMPLATE_FILES = ['fastapi-crud.json', 'data-analytics.json'];
// Ladataan mallipohjat // Ladataan mallipohjat
(async () => { (async () => {
try { for (const file of TEMPLATE_FILES) {
const res = await fetch('/templates/fastapi-crud.json'); try {
if (res.ok) { const t = await res.json(); templates[t.name] = t; } const res = await fetch(`/templates/${file}`);
} catch(e) {} if (res.ok) { const t = await res.json(); templates[t.name] = t; }
} catch(e) {}
}
})(); })();
// Valitaan mallipohja Asiakkaan briefin perusteella (keywords-match)
function selectTemplate(brief) {
const lower = brief.toLowerCase();
let best = null, bestScore = 0;
for (const t of Object.values(templates)) {
const keywords = t.keywords || [];
const score = keywords.filter(k => lower.includes(k)).length;
if (score > bestScore) { bestScore = score; best = t; }
}
return best; // null = vapaa tila
}
function explainStep(title, explanation) { function explainStep(title, explanation) {
termLog(`\n <span style="color:#a371f7;font-size:12px">💡 ${esc(title)}</span>`); termLog(`\n <span style="color:#a371f7;font-size:12px">💡 ${esc(title)}</span>`);
termLog(` <span style="color:#8b949e;font-size:12px">${esc(explanation)}</span>`); termLog(` <span style="color:#8b949e;font-size:12px">${esc(explanation)}</span>`);
} }
async function kpnProject(task) { async function kpnProject(task) {
const cdr = agents.coder || Object.values(agents)[1]; pipelineAbort = new AbortController();
const cli = agents.client || Object.values(agents)[0];
const mgr = agents.manager || Object.values(agents)[1];
const cdr = agents.coder || Object.values(agents)[2];
// Etsitään sopivin mallipohja // Asiakas: jalostaa vaatimukset
const template = Object.values(templates)[0]; // Toistaiseksi vain FastAPI CRUD termLog(`<span style="color:var(--purple);font-weight:bold">━━━ Projekti — ${esc(task)} ━━━</span>`);
if (!template) { termLog(`\n<span style="color:#f0883e;font-weight:bold">[0] ${esc(cli.name)}</span> — vaatimusmäärittely`);
termLog(' ✗ Mallipohjia ei ladattu', '#f85149'); highlightAgent('client');
return; explainStep('Vaatimusmäärittely', `${cli.name} muotoilee idean selkeiksi vaatimuksiksi: ominaisuudet, datamallit, rajapinnat.`);
const brief = await kpnRun(cli.model, `${task}`, false, cli);
if (!brief) { termLog(' ✗ Vaatimusmäärittely epäonnistui', '#f85149'); return; }
termLog(` <span style="color:#8b949e">Vaatimukset valmiit → Manageri</span>`);
// Valitaan mallipohja automaattisesti briefin perusteella
const template = selectTemplate(brief);
// Tiedostolista: mallipohjasta tai managerin dynaamisesta suunnitelmasta
let fileOrder = [];
let fileDefs = {};
if (template) {
// Mallipohja löytyi — käytetään sen rakennetta
fileOrder = template.order;
fileDefs = template.files;
explainStep('Mallipohja', `Tunnistettiin "${template.name}" — ${fileOrder.length} tiedostoa: ${fileOrder.join(', ')}.`);
} else {
// Vapaa tila — Manageri päättää tiedostorakenteen
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] ${esc(mgr.name)}</span> — tiedostorakenne`);
highlightAgent('manager');
explainStep('Vapaa tila', 'Sopivaa mallipohjaa ei löytynyt. Manageri suunnittelee tiedostorakenteen vaatimusten perusteella.');
const planPrompt = `PROJECT REQUIREMENTS:\n${brief}\n\nPlan the file structure for this project. List each file on its own line:\nfilename.ext: one-line description\n\nMaximum 6 files. List dependency files first.`;
const plan = await kpnRun(mgr.model, planPrompt, false, mgr);
if (!plan) { termLog(' ✗ Suunnittelu epäonnistui', '#f85149'); return; }
// Parsitaan managerin tuottama tiedostolista
for (const line of plan.split('\n')) {
const m = line.match(/^\s*[-*]?\s*(\S+\.\w+)\s*[:\-]\s*(.+)/);
if (m) {
const fname = m[1].replace(/^`|`$/g, '');
fileOrder.push(fname);
fileDefs[fname] = { description: m[2].trim(), instructions: m[2].trim() };
}
}
if (fileOrder.length === 0) {
termLog(' ✗ Manageri ei tuottanut tiedostolistaa', '#f85149');
return;
}
explainStep('Suunnitelma', `${fileOrder.length} tiedostoa: ${fileOrder.join(', ')}`);
} }
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ ${esc(template.name)} — ${esc(task)} ━━━</span>`);
explainStep('Mallipohja', `Käytetään "${template.name}" -mallipohjaa jossa ${template.order.length} tiedostoa: ${template.order.join(', ')}. Jokainen tiedosto generoidaan järjestyksessä, ja aiemmat tiedostot annetaan kontekstina seuraavalle.`);
const files = {}; const files = {};
for (let i = 0; i < template.order.length; i++) { for (let i = 0; i < fileOrder.length; i++) {
const fileName = template.order[i]; const fileName = fileOrder[i];
const fileDef = template.files[fileName]; const fileDef = fileDefs[fileName];
if (!fileDef) continue; if (!fileDef) continue;
const step = i + 1; const step = i + 1;
// Valitaan oikea agentti tiedostotyypin mukaan // Valitaan oikea agentti tiedostotyypin mukaan
const isDbFile = fileName === 'models.py' || fileName === 'database.py'; const isDbFile = fileName === 'models.py' || fileName === 'database.py' || fileName === 'etl.py';
const dataAgent = agents.data || Object.values(agents)[2]; const dataAgent = agents.data || Object.values(agents)[3];
const fileAgent = isDbFile && dataAgent ? dataAgent : cdr; const fileAgent = isDbFile && dataAgent ? dataAgent : cdr;
const fileAgentKey = isDbFile && dataAgent ? 'data' : 'coder'; const fileAgentKey = isDbFile && dataAgent ? 'data' : 'coder';
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step}/${template.order.length}] ${esc(fileAgent.name)}</span> — ${esc(fileName)}`); termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step}/${fileOrder.length}] ${esc(fileAgent.name)}</span> — ${esc(fileName)}`);
highlightAgent(fileAgentKey); highlightAgent(fileAgentKey);
// Opettava selitys: miksi tämä tiedosto, mitä se sisältää explainStep(fileName, fileDef.instructions || fileDef.description);
explainStep(fileName, fileDef.instructions);
// Rakennetaan prompti: esimerkki + konteksti + ohje // Rakennetaan prompti
let prompt = ''; let prompt = '';
// Agentin system prompt (data-agentti models.py:lle, koodari muille)
if (fileAgent.prompt) prompt += fileAgent.prompt + '\n\n'; if (fileAgent.prompt) prompt += fileAgent.prompt + '\n\n';
// Esimerkki (few-shot) // Esimerkki (vain mallipohjatilassa)
prompt += `EXAMPLE of ${fileName} (for a different project, adapt to this one):\n`; if (fileDef.example) {
prompt += '```\n' + fileDef.example + '\n```\n\n'; prompt += `EXAMPLE of ${fileName} (for a different project, adapt to this one):\n`;
prompt += '```\n' + fileDef.example + '\n```\n\n';
}
// Aiemmin generoidut tiedostot (konteksti) // Aiemmin generoidut tiedostot (konteksti)
const prevFiles = Object.entries(files); const prevFiles = Object.entries(files);
@@ -787,12 +884,15 @@ OUTPUT FORMAT:
} }
} }
// Asiakkaan vaatimusmäärittely
prompt += `PROJECT REQUIREMENTS (from product owner):\n${brief}\n\n`;
// Tehtävä // Tehtävä
prompt += `NOW write "${fileName}" for THIS project: ${task}\n`; prompt += `NOW write "${fileName}" for THIS project: ${task}\n`;
prompt += fileDef.instructions + '\n'; if (fileDef.instructions) prompt += fileDef.instructions + '\n';
prompt += 'Adapt the example to match the project description. Import from already written files. Write ONLY the code, no explanations.'; prompt += 'Adapt to the project requirements. Import from already written files. Write ONLY the code, no explanations.';
const code = await kpnRun(fileAgent.model, prompt); const code = await kpnRun(fileAgent.model, prompt, false, fileAgent);
if (!code) { if (!code) {
termLog(` ✗ Keskeytyi (${fileName})`, '#f85149'); termLog(` ✗ Keskeytyi (${fileName})`, '#f85149');
return; return;
@@ -804,7 +904,7 @@ OUTPUT FORMAT:
let stepN = template.order.length + 1; let stepN = template.order.length + 1;
// Review-korjausluuppi: max 2 kierrosta // Review-korjausluuppi: max 2 kierrosta
const tst = agents.tester || Object.values(agents)[4]; const tst = agents.tester || Object.values(agents)[5];
const MAX_REVIEW_ROUNDS = 3; const MAX_REVIEW_ROUNDS = 3;
for (let round = 0; round < MAX_REVIEW_ROUNDS; round++) { for (let round = 0; round < MAX_REVIEW_ROUNDS; round++) {
@@ -817,7 +917,7 @@ OUTPUT FORMAT:
else explainStep('Uudelleentarkistus', `${tst.name} tarkistaa korjaukset.`); else explainStep('Uudelleentarkistus', `${tst.name} tarkistaa korjaukset.`);
const reviewPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') + `Review this project:\n\n${currentCode}`; const reviewPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') + `Review this project:\n\n${currentCode}`;
const review = await kpnRun(tst.model, reviewPrompt); const review = await kpnRun(tst.model, reviewPrompt, false, tst);
stepN++; stepN++;
// LGTM → ei korjauksia tarvita // LGTM → ei korjauksia tarvita
@@ -832,7 +932,7 @@ OUTPUT FORMAT:
explainStep('Korjaus', `${tst.name} löysi ongelmia. ${cdr.name} saa palautteen ja korjaa.`); explainStep('Korjaus', `${tst.name} löysi ongelmia. ${cdr.name} saa palautteen ja korjaa.`);
const fixPrompt = `${cdr.prompt ? cdr.prompt+'\n\n' : ''}Fix these issues:\n${review}\n\nCurrent code:\n${currentCode}\n\nWrite ALL corrected files. Start each file with: --- filename.py ---`; const fixPrompt = `${cdr.prompt ? cdr.prompt+'\n\n' : ''}Fix these issues:\n${review}\n\nCurrent code:\n${currentCode}\n\nWrite ALL corrected files. Start each file with: --- filename.py ---`;
const fixedCode = await kpnRun(cdr.model, fixPrompt); const fixedCode = await kpnRun(cdr.model, fixPrompt, false, cdr);
// Parsitaan korjatut tiedostot takaisin files-objektiin // Parsitaan korjatut tiedostot takaisin files-objektiin
if (fixedCode) { if (fixedCode) {
@@ -852,13 +952,13 @@ OUTPUT FORMAT:
const updatedCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n'); const updatedCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
// QA: testit (saa korjatut tiedostot) // QA: testit (saa korjatut tiedostot)
const qaAgent = agents.qa || Object.values(agents)[3]; const qaAgent = agents.qa || Object.values(agents)[4];
if (qaAgent) { if (qaAgent) {
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — testit`); termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — testit`);
highlightAgent('qa'); highlightAgent('qa');
explainStep('Testit', `${qaAgent.name} kirjoittaa pytest-testit korjatulle koodille.`); explainStep('Testit', `${qaAgent.name} kirjoittaa pytest-testit korjatulle koodille.`);
const qaPrompt = (qaAgent.prompt ? qaAgent.prompt+'\n\n' : '') + `Write pytest tests for this project:\n\n${updatedCode}\n\nWrite a complete test_main.py file with TestClient.`; const qaPrompt = (qaAgent.prompt ? qaAgent.prompt+'\n\n' : '') + `Write pytest tests for this project:\n\n${updatedCode}\n\nWrite a complete test_main.py file with TestClient.`;
const tests = await kpnRun(qaAgent.model, qaPrompt); const tests = await kpnRun(qaAgent.model, qaPrompt, false, qaAgent);
if (tests) files['test_main.py'] = tests; if (tests) files['test_main.py'] = tests;
stepN++; stepN++;
} }
@@ -878,12 +978,12 @@ OUTPUT FORMAT:
`- Expose port 8000\n` + `- Expose port 8000\n` +
`- CMD: uv run uvicorn main:app --host 0.0.0.0 --port 8000\n` + `- CMD: uv run uvicorn main:app --host 0.0.0.0 --port 8000\n` +
`\nWrite ONLY the Dockerfile, no explanations.`; `\nWrite ONLY the Dockerfile, no explanations.`;
const dockerfile = await kpnRun(tst.model, dockerPrompt); const dockerfile = await kpnRun(tst.model, dockerPrompt, false, tst);
if (dockerfile) files['Dockerfile'] = dockerfile; if (dockerfile) files['Dockerfile'] = dockerfile;
stepN++; stepN++;
// Tarkkailija: yhteenveto + raportti + arvosana // Tarkkailija: yhteenveto + raportti + arvosana
const obs = agents.observer || Object.values(agents)[5]; const obs = agents.observer || Object.values(agents)[6];
if (obs) { if (obs) {
termLog(`\n<span style="color:#8b949e;font-weight:bold">[${stepN}] ${esc(obs.name)}</span> — projektin yhteenveto`); termLog(`\n<span style="color:#8b949e;font-weight:bold">[${stepN}] ${esc(obs.name)}</span> — projektin yhteenveto`);
highlightAgent('observer'); highlightAgent('observer');
@@ -913,7 +1013,7 @@ OUTPUT FORMAT:
`## Architecture\nDescribe the project structure and design decisions.\n\n` + `## Architecture\nDescribe the project structure and design decisions.\n\n` +
`## Risk Assessment\n| Severity | Issue |\n|----------|-------|\n| ... | ... |\n\n` + `## Risk Assessment\n| Severity | Issue |\n|----------|-------|\n| ... | ... |\n\n` +
`Project code:\n${finalCode}`; `Project code:\n${finalCode}`;
const readme = await kpnRun(obs.model, obsPrompt); const readme = await kpnRun(obs.model, obsPrompt, false, obs);
if (readme) { if (readme) {
files['README.md'] = readme; files['README.md'] = readme;
// Tallennetaan raportti globaalisti jotta tarkkailija-klikkaus avaa sen // Tallennetaan raportti globaalisti jotta tarkkailija-klikkaus avaa sen
@@ -944,15 +1044,25 @@ OUTPUT FORMAT:
} }
async function kpnPipelineSimple(task) { async function kpnPipelineSimple(task) {
pipelineAbort = new AbortController();
const cli = agents.client || Object.values(agents)[0];
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ Pipeline ━━━</span>`); termLog(`<span style="color:var(--purple);font-weight:bold">━━━ Pipeline ━━━</span>`);
termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span>`); termLog(`\n<span style="color:#f0883e;font-weight:bold">[1/4] ${esc(cli.name)}</span> — vaatimukset`);
const plan = await kpnRun('qwen-coder', `Analyse briefly, write a spec:\n${task}`); highlightAgent('client');
const brief = await kpnRun(cli.model, `${task}`, false, cli);
if (!brief) return;
termLog(`\n<span style="color:#d29922;font-weight:bold">[2/4] Manageri</span>`);
highlightAgent('manager');
const plan = await kpnRun('qwen-coder', `Requirements:\n${brief}\n\nAnalyse briefly, write a spec:\n${task}`);
if (!plan) return; if (!plan) return;
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span>`); termLog(`\n<span style="color:#3fb950;font-weight:bold">[3/4] Koodari</span>`);
highlightAgent('coder');
const code = await kpnRun('qwen-coder', `${plan}\n\nWrite the code.`); const code = await kpnRun('qwen-coder', `${plan}\n\nWrite the code.`);
if (!code) return; if (!code) return;
termLog(`\n<span style="color:var(--accent);font-weight:bold">[3/3] Testaaja</span>`); termLog(`\n<span style="color:var(--accent);font-weight:bold">[4/4] Testaaja</span>`);
highlightAgent('tester');
await kpnRun('qwen-coder', `Review briefly:\n${code}`); await kpnRun('qwen-coder', `Review briefly:\n${code}`);
highlightAgent(null);
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis ━━━</span>`); termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis ━━━</span>`);
} }

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

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "hub" name = "hub"
version = "0.3.1" version = "0.3.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -11,7 +11,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] }
futures = "0.3" futures = "0.3"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
chrono = "0.4" chrono = "0.4"

View File

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

View File

@@ -25,7 +25,7 @@ const ALLOWED_ORIGINS: &[&str] = &[
]; ];
// Sallitut viestityyypit clientilta // Sallitut viestityyypit clientilta
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "llm_error", "download_progress", "user_text", "single_tokenize_done"]; const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "llm_error", "download_progress", "user_text", "single_tokenize_done", "status_update"];
struct AppState { struct AppState {
next_node_id: Mutex<u64>, next_node_id: Mutex<u64>,
@@ -40,9 +40,12 @@ 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ä
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
db: db::NodeDb, db: db::NodeDb,
} }
@@ -80,6 +83,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 +96,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 +105,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 +124,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 +168,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 +196,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 +213,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 +236,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 +265,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 +327,12 @@ 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()),
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()),
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 +419,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 +447,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 +467,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 +650,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 +828,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 +844,12 @@ 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
if let Some(models) = json.get("models") {
let mut nm = state.node_models.write().await;
nm.insert(node_id, models.clone());
}
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 +887,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);
{ {
@@ -875,11 +994,18 @@ 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()) { let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
state.pending_task_ids.lock().unwrap().remove(tid) 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() {
@@ -899,6 +1025,12 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
state.db.increment_tasks(node_id); state.db.increment_tasks(node_id);
obj.insert("node_id".to_string(), serde_json::json!(node_id)); obj.insert("node_id".to_string(), serde_json::json!(node_id));
} }
if let Some(sender) = api_sender {
// API-pyyntö: reititetään vastaus suoraan odottajalle
let _ = sender.send(json.clone());
}
// UI-broadcast jatkuu normaalisti
let _ = state.stats_tx.send(json.to_string()); let _ = state.stats_tx.send(json.to_string());
let active_incentives = state.feature_flags.read().await.get("Insentiivit").copied().unwrap_or(false); let active_incentives = state.feature_flags.read().await.get("Insentiivit").copied().unwrap_or(false);
@@ -908,7 +1040,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
{ {
let mut task_count = state.total_tasks.lock().unwrap(); let mut task_count = state.total_tasks.lock().unwrap();
*task_count += 1; *task_count += 1;
if active_incentives && valid_task { if active_incentives && valid_task {
let mut tokens = state.nodes_tokens.lock().unwrap(); let mut tokens = state.nodes_tokens.lock().unwrap();
let balance = tokens.entry(node_id).or_insert(0); let balance = tokens.entry(node_id).or_insert(0);
@@ -916,7 +1048,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
current_balance = *balance; current_balance = *balance;
} }
} }
if active_incentives && ui_sync { if active_incentives && ui_sync {
if let Some(tx) = state.node_channels.read().await.get(&node_id) { if let Some(tx) = state.node_channels.read().await.get(&node_id) {
let msg = serde_json::json!({ let msg = serde_json::json!({
@@ -926,45 +1058,50 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
let _ = tx.send(msg.to_string()); let _ = tx.send(msg.to_string());
} }
} }
broadcast_stats(&state).await; broadcast_stats(&state).await;
} }
} else if msg_type == "llm_error" { } else if msg_type == "llm_error" {
state.node_busy.lock().unwrap().remove(&node_id); state.node_busy.lock().unwrap().remove(&node_id);
if let Some(tid) = json.get("task_id").and_then(|v| v.as_str()) { let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
state.pending_task_ids.lock().unwrap().remove(tid); if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str());
} }
// Jos API-pyyntö odottaa, reititetään virhe oneshot-kanavaan
let api_sender = task_id.as_ref().and_then(|tid| {
state.pending_responses.lock().unwrap().remove(tid)
});
{ {
let mut json = json; let mut json = json;
if let Some(obj) = json.as_object_mut() { if let Some(obj) = json.as_object_mut() {
obj.insert("node_id".to_string(), serde_json::json!(node_id)); obj.insert("node_id".to_string(), serde_json::json!(node_id));
} }
if let Some(sender) = api_sender {
let _ = sender.send(json.clone());
}
let _ = state.stats_tx.send(json.to_string()); let _ = state.stats_tx.send(json.to_string());
} }
} else if msg_type == "user_text" { } else if msg_type == "user_text" {
// Käyttäjän lähettämä teksti — broadcastataan pair_taskina ja llm_promptina // Käyttäjän lähettämä teksti — kohdennettu reititys lähettäjäsolmulle
let text = json.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); let text = json.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string();
let task_type = json.get("task_type").and_then(|v| v.as_str()).unwrap_or("tokenize"); let task_type = json.get("task_type").and_then(|v| v.as_str()).unwrap_or("tokenize");
if !text.is_empty() { if !text.is_empty() {
let preview: String = text.chars().take(80).collect(); let preview: String = text.chars().take(80).collect();
tracing::info!("Solmu {} lähetti oman tekstin ({}): \"{}\"", node_id, task_type, preview); tracing::info!("Solmu {} lähetti oman tekstin ({}): \"{}\"", node_id, task_type, preview);
match task_type { let msg = match task_type {
"tokenize" => { "tokenize" => serde_json::json!({
let msg = serde_json::json!({ "type": "single_tokenize",
"type": "single_tokenize", "text": text,
"text": text, }),
}); _ => serde_json::json!({
let _ = state.stats_tx.send(msg.to_string()); "type": "llm_prompt",
} "prompt": text,
_ => { "model": task_type,
// LLM-prompti: lähetetään VAIN valitulle mallille, ei kaikille (välttää turhaa ruuhkaa ja busy-tiloja) }),
let prompt = serde_json::json!({ };
"type": "llm_prompt", // Lähetetään takaisin lähettäjäsolmulle (käyttäjä haluaa oman tekstinsä tuloksen)
"prompt": text, if let Some(tx) = state.node_channels.read().await.get(&node_id) {
"model": task_type, let _ = tx.send(msg.to_string());
});
let _ = state.stats_tx.send(prompt.to_string());
}
} }
} }
} }
@@ -989,6 +1126,8 @@ 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);
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 +1139,16 @@ 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>>,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -1009,7 +1158,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 +1191,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)
@@ -1106,7 +1263,9 @@ async fn api_chat_completions(
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();
let matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
if paused.contains(k) { return false; } // Ei sallita tauotettuja
// Eksakti match tai qwen-perheen yhteensopivuus (selain: qwen-coder-05b, natiivi: qwen2.5-coder:7b) // Eksakti match tai qwen-perheen yhteensopivuus (selain: qwen-coder-05b, natiivi: qwen2.5-coder:7b)
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();
@@ -1157,12 +1316,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 +1335,34 @@ 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(600), resp_rx).await;
loop {
let msg_str = match rx.recv().await {
Ok(msg) => msg,
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!("API-kanava lagged {} viestiä", n);
continue;
}
Err(_) => return Ok(None), // Kanava suljettu
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg_str) {
if v["type"].as_str() == Some("llm_done") {
if let Some(tid) = v["task_id"].as_str() {
if tid == payload.task_id {
return Ok(Some(ChatCompletionResponse {
response: v["response"].as_str().unwrap_or("").to_string(),
model: v["model"].as_str().unwrap_or("").to_string(),
tokens_generated: v["tokens_generated"].as_u64().unwrap_or(0),
}));
}
}
} else if v["type"].as_str() == Some("llm_error") {
if let Some(tid) = v["task_id"].as_str() {
if tid == payload.task_id {
return Err(v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string());
}
}
}
}
}
#[allow(unreachable_code)]
Ok(None)
}).await;
match timeout { match timeout {
Ok(Ok(Some(res))) => axum::Json(res).into_response(), Ok(Ok(v)) => {
Ok(Ok(None)) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response(), if v["type"].as_str() == Some("llm_error") {
Ok(Err(err)) => (axum::http::StatusCode::CONFLICT, err).into_response(), let err = v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string();
Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response(), (axum::http::StatusCode::CONFLICT, err).into_response()
} else {
axum::Json(ChatCompletionResponse {
response: v["response"].as_str().unwrap_or("").to_string(),
model: v["model"].as_str().unwrap_or("").to_string(),
tokens_generated: v["tokens_generated"].as_u64().unwrap_or(0),
}).into_response()
}
}
Ok(Err(_)) => {
// Oneshot-kanava sulkeutui (solmu katosi)
state.pending_responses.lock().unwrap().remove(&payload.task_id);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response()
}
Err(_) => {
state.pending_responses.lock().unwrap().remove(&payload.task_id);
(axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response()
}
} }
} }

View File

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

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,7 @@ 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"

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 })
} }
@@ -78,28 +91,55 @@ impl LlmEngine {
} }
} }
pub async fn generate(&self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> { /// Hakee kaikki Ollamaan asennetut mallit
// System prompt tulee agentin konfiguraatiosta (frontend lähettää sen osana promptia). pub async fn fetch_models(&self) -> Result<serde_json::Value, String> {
// Tässä ei yliajeta sitä — Ollama saa vain prompt-kentän. let resp = self.client.get(format!("{}/api/tags", 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 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_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 +148,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 +167,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 {

View File

@@ -1,10 +1,13 @@
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde_json::json; use serde_json::json;
use std::io::IsTerminal;
use sysinfo::System; use sysinfo::System;
use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
mod inference; mod inference;
mod tui;
mod tui_dashboard;
/// GPU-tietorakenne — yhtenäinen kaikille valmistajille /// GPU-tietorakenne — yhtenäinen kaikille valmistajille
struct GpuInfo { struct GpuInfo {
@@ -222,7 +225,7 @@ fn collect_system_info() -> serde_json::Value {
} }
/// Koko auth-viesti hubille /// Koko auth-viesti hubille
fn build_auth_message(allocated_gb: u32) -> String { fn build_auth_message(allocated_gb: u32, model_name: &str, models_data: Option<serde_json::Value>) -> String {
let sys = collect_system_info(); let sys = collect_system_info();
let gpus = collect_all_gpus(); let gpus = collect_all_gpus();
@@ -239,7 +242,7 @@ fn build_auth_message(allocated_gb: u32) -> String {
"status": "agent_ready", "status": "agent_ready",
"node_type": "native", "node_type": "native",
"allocated_gb": allocated_gb, "allocated_gb": allocated_gb,
"selected_task": "qwen2.5-coder:7b", "selected_task": model_name,
"system": sys, "system": sys,
}); });
@@ -251,6 +254,10 @@ fn build_auth_message(allocated_gb: u32) -> String {
msg.as_object_mut().unwrap().insert("gpus".to_string(), json!(gpu_json)); msg.as_object_mut().unwrap().insert("gpus".to_string(), json!(gpu_json));
} }
if let Some(models) = models_data {
msg.as_object_mut().unwrap().insert("models".to_string(), models);
}
msg.to_string() msg.to_string()
} }
@@ -263,10 +270,24 @@ fn format_optional<T: std::fmt::Display>(val: Option<T>, suffix: &str) -> String
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let file_appender = tracing_appender::rolling::never(".", "native-node.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter("native_node=debug") .with_env_filter("native_node=debug")
.with_writer(non_blocking)
.init(); .init();
// Hookataan paniikkitilanteet palauttamaan terminaalin raw-moodista
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
tui_dashboard::restore_terminal();
original_hook(panic_info);
}));
let tui_state = std::sync::Arc::new(tokio::sync::RwLock::new(tui_dashboard::DashboardState::new()));
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let hub_url = std::env::var("HUB_URL").unwrap_or_else(|_| "ws://hub:3000/ws".to_string()); let hub_url = std::env::var("HUB_URL").unwrap_or_else(|_| "ws://hub:3000/ws".to_string());
let allocated_gb: u32 = std::env::var("ALLOCATED_GB") let allocated_gb: u32 = std::env::var("ALLOCATED_GB")
.ok() .ok()
@@ -282,6 +303,18 @@ async fn main() {
sys["cpu_cores"], sys["cpu_cores"],
sys["ram_total_mb"] sys["ram_total_mb"]
); );
{
let mut st = tui_state.write().await;
st.sys_info = format!("{} | {} | {} ydintä | {} MB RAM",
sys["hostname"].as_str().unwrap_or("?"),
sys["os"].as_str().unwrap_or("?"),
sys["cpu_cores"],
sys["ram_total_mb"]
);
let i = st.sys_info.clone();
st.push_log("System", format!("Järjestelmä: {}", i), None);
}
let gpus = collect_all_gpus(); let gpus = collect_all_gpus();
if gpus.is_empty() { if gpus.is_empty() {
@@ -321,6 +354,40 @@ 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);
}
// 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,80 +395,226 @@ 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 { loop {
if let Message::Text(text) = msg { tokio::select! {
// LLM-promptit cmd = cmd_rx.recv() => {
if text.contains("llm_prompt") { if let Some(cmd_str) = cmd {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) { if cmd_str == "pause" {
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); tracing::info!("Tauotetaan solmun suoritus (Hub ei lähetä tehtäviä)...");
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?"); let req = json!({"type": "status_update", "status": "paused"});
let msg_model = task.get("model").and_then(|v| v.as_str()).unwrap_or(""); let _ = write.send(Message::Text(req.to_string())).await;
{
if !prompt.is_empty() && (msg_model.starts_with("qwen-coder") || msg_model.starts_with("qwen2.5-coder")) { let mut st = tui_state.write().await;
st.status = "PAUSED".to_string();
st.push_log("Network", "Solmu siirretty taukotilaan".to_string(), None);
}
} else if cmd_str == "resume" {
tracing::info!("Jatketaan solmun suoritusta...");
let req = json!({"type": "status_update", "status": "active"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("System", "Suoritus jatkuu...".to_string(), None);
}
} else if cmd_str == "fetch_models" {
// Haetaan mallit Ollamasta ja avataan valikkö
if let Some(ref engine) = llm { if let Some(ref engine) = llm {
let max_tokens = task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(1024) as usize; match engine.fetch_models().await {
let prompt_lines = prompt.lines().count(); Ok(tags) => {
let prompt_last: String = prompt.lines().last().unwrap_or("").chars().take(60).collect(); let models: Vec<String> = tags.get("models")
tracing::info!("→ task_id:{} | {}r prompti | \"{}...\"", task_id, prompt_lines, prompt_last); .and_then(|v| v.as_array())
.map(|arr| arr.iter()
let model_name = engine.model_name(); .filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
match engine.generate(prompt, max_tokens).await { .collect())
Ok(result) => { .unwrap_or_default();
tracing::info!( let mut st = tui_state.write().await;
"✓ {} | {} tok | {:.0}ms | {:.1} tok/s", st.model_picker_items = models;
model_name, st.model_picker_idx = 0;
result.tokens_generated, st.model_picker_open = true;
result.duration_ms,
result.tokens_per_sec,
);
// Lähetetään vain lyhyt prompti-esikatselu (ei koko kontekstia)
let prompt_short: String = prompt.lines().last().unwrap_or("").chars().take(100).collect();
let done = json!({
"type": "llm_done",
"prompt": prompt_short,
"model": format!("{} (Ollama)", model_name),
"response": result.text,
"tokens_generated": result.tokens_generated,
"duration_ms": result.duration_ms,
"tokens_per_sec": (result.tokens_per_sec * 10.0).round() / 10.0,
"load_time_ms": 0,
"task_id": task_id,
});
let _ = write.send(Message::Text(done.to_string())).await;
} }
Err(e) => { Err(e) => {
tracing::error!("Inferenssivirhe: {}", e); let mut st = tui_state.write().await;
st.push_log("System", format!("Mallilistan haku epäonnistui: {}", e), None);
}
}
}
} else if let Some(model) = cmd_str.strip_prefix("change_model:") {
// TUI:sta valittu malli — vaihdetaan
if let Some(ref engine) = llm {
engine.set_model(model.to_string());
match engine.ensure_model().await {
Ok(()) => {
tracing::info!("Malli vaihdettu: {}", model);
let mut st = tui_state.write().await;
st.model_name = model.to_string();
st.push_log("System", format!("Malli vaihdettu: {}", model), None);
// Ilmoitetaan hubille
let auth = build_auth_message(allocated_gb, model, available_models.clone());
let _ = write.send(Message::Text(auth)).await;
}
Err(e) => {
let mut st = tui_state.write().await;
st.push_log("System", format!("Mallin vaihto epäonnistui: {}", e), None);
} }
} }
} }
} }
} }
} }
// Mallin vaihto lennossa ws_msg = read.next() => {
if text.contains("change_model") { match ws_msg {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) { Some(Ok(Message::Text(text))) => {
if let Some(new_model) = task.get("model").and_then(|v| v.as_str()) { // Hubin control-viestit
if let Some(ref engine) = llm { if text.contains(r#""type":"control""#) {
tracing::info!("Vaihdetaan malli: {}", new_model); if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
engine.set_model(new_model.to_string()); if let Some(action) = task.get("action").and_then(|v| v.as_str()) {
match engine.ensure_model().await { if action == "pause" {
Ok(()) => tracing::info!("Malli {} valmis!", new_model), tracing::info!("Hub pakotti solmun tauolle (Pause)");
Err(e) => tracing::error!("Mallin lataus epäonnistui: {}", e), let req = json!({"type": "status_update", "status": "paused"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "PAUSED".to_string();
st.push_log("Network", "Hub kytki solmun tauolle".to_string(), None);
}
} else if action == "resume" {
tracing::info!("Hub aktivoi solmun suorituksen (Resume)");
let req = json!({"type": "status_update", "status": "active"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("Network", "Hub palautti solmun töihin".to_string(), None);
}
}
}
}
}
// Verkon globaali tila
if text.contains(r#""type":"network_status""#) {
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(nodes) = status.get("active_nodes").and_then(|v| v.as_u64()) {
if let Some(tasks) = status.get("tasks").and_then(|v| v.as_u64()) {
let mut st = tui_state.write().await;
st.network_active_nodes = nodes as usize;
st.network_total_tasks = tasks;
}
}
}
}
// LLM-promptit
if text.contains("llm_prompt") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
let msg_model = task.get("model").and_then(|v| v.as_str()).unwrap_or("");
if !prompt.is_empty() && (msg_model.starts_with("qwen-coder") || msg_model.starts_with("qwen2.5-coder") || msg_model.starts_with("phi")) {
if let Some(ref engine) = llm {
let gen_opts = inference::GenerateOptions {
max_tokens: task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(1024) as usize,
system_prompt: task.get("system_prompt").and_then(|v| v.as_str()).map(|s| s.to_string()),
temperature: task.get("temperature").and_then(|v| v.as_f64()),
top_k: task.get("top_k").and_then(|v| v.as_u64()),
repeat_penalty: task.get("repeat_penalty").and_then(|v| v.as_f64()),
stop: task.get("stop").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(|s| s.to_string())).collect()),
};
let prompt_lines = prompt.lines().count();
let prompt_last: String = prompt.lines().last().unwrap_or("").chars().take(60).collect();
tracing::info!("→ task_id:{} | {}r prompti | \"{}...\"", task_id, prompt_lines, prompt_last);
{
let mut st = tui_state.write().await;
st.cur_task_id = Some(task_id.to_string());
st.cur_prompt = Some(format!("{} riviä | \"{}...\"", prompt_lines, prompt_last));
}
let model_name = engine.model_name();
match engine.generate(prompt, &gen_opts).await {
Ok(result) => {
let tokens_sec = (result.tokens_per_sec * 10.0).round() / 10.0;
tracing::info!(
"✓ {} | {} tok | {:.0}ms | {:.1} tok/s",
model_name,
result.tokens_generated,
result.duration_ms,
tokens_sec,
);
{
let mut st = tui_state.write().await;
st.tasks_completed += 1;
st.last_tokens_sec = tokens_sec as f64;
st.cur_task_id = None;
st.cur_prompt = None;
let msg_type = if task_id == "status-check" { "Ping" } else { "Task" };
let msg_text = format!("{} ({} tok)", task_id, result.tokens_generated);
st.push_log(msg_type, msg_text, Some(tokens_sec as f64));
}
let prompt_short: String = prompt.lines().last().unwrap_or("").chars().take(100).collect();
let done = json!({
"type": "llm_done",
"prompt": prompt_short,
"model": format!("{} (Ollama)", model_name),
"response": result.text,
"tokens_generated": result.tokens_generated,
"duration_ms": result.duration_ms,
"tokens_per_sec": tokens_sec,
"load_time_ms": 0,
"task_id": task_id,
});
let _ = write.send(Message::Text(done.to_string())).await;
}
Err(e) => {
tracing::error!("Inferenssivirhe: {}", e);
{
let mut st = tui_state.write().await;
st.cur_task_id = None;
st.cur_prompt = None;
st.push_log("System", format!("Virhe inferenssissä: {}", e), None);
}
}
}
}
}
}
}
// Mallin vaihto lennossa
if text.contains("change_model") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(new_model) = task.get("model").and_then(|v| v.as_str()) {
if let Some(ref engine) = llm {
tracing::info!("Vaihdetaan malli: {}", new_model);
engine.set_model(new_model.to_string());
match engine.ensure_model().await {
Ok(()) => {
tracing::info!("Malli {} valmis!", new_model);
let mut st = tui_state.write().await;
st.model_name = new_model.to_string();
st.push_log("System", format!("Malli {} ladattu & valmis!", new_model), None);
}
Err(e) => tracing::error!("Mallin lataus epäonnistui: {}", e),
}
}
}
} }
} }
} }
Some(Ok(_)) => {} // Muut viestityypit (binary/ping)
Some(Err(_)) | None => break, // Yhteys poikki
} }
} }
} }
} }
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s..."); tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
} }
Err(e) => { Err(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,296 @@
use crossterm::{
event::{self, 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 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,
// 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,
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>) {
self.logs.push(LogEntry {
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(7), // 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
let info_text = format!(
"🚀 Malli: {}\n💻 Järjestelmä: {}\n📊 Tehdyt: {} | Nopeus: {} t/s\n🌐 Verkosto: {} solmua | {} tehtävää",
st.model_name, st.sys_info, st.tasks_completed, st.last_tokens_sec, st.network_active_nodes, st.network_total_tasks
);
let left_panel = Paragraph::new(info_text)
.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(format!("{: <8}", log.ty), Style::default().fg(ty_color).add_modifier(Modifier::BOLD)),
ratatui::text::Span::raw(" | "),
ratatui::text::Span::styled(log.msg.clone(), Style::default().fg(Color::White)),
ratatui::text::Span::styled(speed_str, Style::default().fg(ty_color)),
])
}).collect();
let logs_panel = Paragraph::new(visible_logs)
.block(Block::default().title(" Tapahtumaloki ").borders(Borders::ALL).style(Style::default().fg(Color::Cyan)));
f.render_widget(logs_panel, body_chunks[1]);
// --- Footer / Status ---
let status_color = if st.status == "ACTIVE" { Color::Green } else { Color::Yellow };
let status_text = format!(" Tila: {} | [P] Pause [R] Työhön [M] Malli [Q] Sulje ", st.status);
let footer = Paragraph::new(status_text)
.style(Style::default().fg(status_color).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(footer, chunks[2]);
// --- Mallivalikko-overlay ---
if st.model_picker_open && !st.model_picker_items.is_empty() {
let area = f.area();
let popup_h = (st.model_picker_items.len() as u16 + 4).min(area.height - 4);
let popup_w = 50.min(area.width - 4);
let popup = ratatui::layout::Rect::new(
(area.width - popup_w) / 2,
(area.height - popup_h) / 2,
popup_w,
popup_h,
);
// Tausta
f.render_widget(ratatui::widgets::Clear, popup);
let items: Vec<ratatui::text::Line> = st.model_picker_items.iter().enumerate().map(|(i, name)| {
if i == st.model_picker_idx {
ratatui::text::Line::from(format!("{} ", name))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
} else {
ratatui::text::Line::from(format!(" {} ", name))
.style(Style::default().fg(Color::White))
}
}).collect();
let picker = Paragraph::new(items)
.block(Block::default()
.title(" Vaihda malli [↑↓] Enter=valitse Esc=peruuta ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan)));
f.render_widget(picker, popup);
}
}

View File

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

View File

@@ -1,118 +0,0 @@
use burn::module::{Module, Param};
use burn::tensor::{backend::Backend, Tensor};
use super::rope::RoPE;
use super::config::SmolLMConfig;
#[derive(Clone, Debug)]
pub struct KVCache<B: Backend> {
pub k: Tensor<B, 4>,
pub v: Tensor<B, 4>,
}
#[derive(Module, Debug)]
pub struct Attention<B: Backend> {
pub q_proj: Param<Tensor<B, 2>>, // [hidden, num_heads * head_dim]
pub k_proj: Param<Tensor<B, 2>>, // [hidden, num_kv_heads * head_dim]
pub v_proj: Param<Tensor<B, 2>>, // [hidden, num_kv_heads * head_dim]
pub o_proj: Param<Tensor<B, 2>>, // [num_heads * head_dim, hidden]
num_heads: usize,
num_kv_heads: usize,
head_dim: usize,
rope: RoPE<B>,
}
impl<B: Backend> Attention<B> {
pub fn new(config: &SmolLMConfig, device: &B::Device) -> Self {
let head_dim = config.hidden_size / config.num_attention_heads;
Self {
q_proj: Param::from_tensor(Tensor::zeros([config.hidden_size, config.num_attention_heads * head_dim], device)),
k_proj: Param::from_tensor(Tensor::zeros([config.hidden_size, config.num_key_value_heads * head_dim], device)),
v_proj: Param::from_tensor(Tensor::zeros([config.hidden_size, config.num_key_value_heads * head_dim], device)),
o_proj: Param::from_tensor(Tensor::zeros([config.num_attention_heads * head_dim, config.hidden_size], device)),
num_heads: config.num_attention_heads,
num_kv_heads: config.num_key_value_heads,
head_dim,
rope: RoPE::new(head_dim, config.max_position_embeddings, config.rope_theta, device),
}
}
pub fn forward(
&self,
x: Tensor<B, 3>,
offset: usize,
cache: Option<KVCache<B>>
) -> (Tensor<B, 3>, KVCache<B>) {
let [batch, seq_len, hidden_dim] = x.dims();
// Project Q, K, V: x @ W -> [batch, seq, proj_dim]
let q = x.clone().matmul(self.q_proj.val().unsqueeze());
let k = x.clone().matmul(self.k_proj.val().unsqueeze());
let v = x.matmul(self.v_proj.val().unsqueeze());
// Reshape: [batch, seq, heads, head_dim] -> [batch, heads, seq, head_dim]
let q = q.reshape([batch, seq_len, self.num_heads, self.head_dim]).swap_dims(1, 2);
let k = k.reshape([batch, seq_len, self.num_kv_heads, self.head_dim]).swap_dims(1, 2);
let v = v.reshape([batch, seq_len, self.num_kv_heads, self.head_dim]).swap_dims(1, 2);
// Apply RoPE
let q = self.rope.forward(q, offset);
let k = self.rope.forward(k, offset);
// KV cache
let (k, v) = if let Some(c) = cache {
(Tensor::cat(vec![c.k, k], 2), Tensor::cat(vec![c.v, v], 2))
} else {
(k, v)
};
let new_cache = KVCache { k: k.clone(), v: v.clone() };
let kv_len = k.dims()[2];
// GQA: repeat K,V heads — [batch, kv_heads, kv_len, hd] -> [batch, num_heads, kv_len, hd]
let num_reps = self.num_heads / self.num_kv_heads;
let k = if num_reps > 1 {
let [b, kv_h, s, hd] = k.dims();
k.reshape([b, kv_h, 1, s, hd]).repeat_dim(2, num_reps).reshape([b, self.num_heads, s, hd])
} else { k };
let v = if num_reps > 1 {
let [b, kv_h, s, hd] = v.dims();
v.reshape([b, kv_h, 1, s, hd]).repeat_dim(2, num_reps).reshape([b, self.num_heads, s, hd])
} else { v };
// Attention: Q @ K^T / sqrt(d)
let scale = 1.0 / (self.head_dim as f64).sqrt();
let scores = q.matmul(k.swap_dims(2, 3)).mul_scalar(scale);
// scores: [batch, heads, seq_len, kv_len]
// Causal mask for prefill (seq_len > 1)
let scores = if seq_len > 1 {
let mask_data: Vec<f32> = (0..seq_len).flat_map(|i| {
(0..kv_len).map(move |j| {
if j > offset + i { f32::NEG_INFINITY } else { 0.0 }
})
}).collect();
let mask = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(mask_data, [seq_len, kv_len]),
&scores.device()
).reshape([1, 1, seq_len, kv_len]);
scores + mask
} else {
scores
};
let attn_weights = burn::tensor::activation::softmax(scores, 3);
let context = attn_weights.matmul(v);
// [batch, heads, seq, hd] -> [batch, seq, heads*hd]
let context = context.swap_dims(1, 2).reshape([batch, seq_len, self.num_heads * self.head_dim]);
let output = context.matmul(self.o_proj.val().unsqueeze());
(output, new_cache)
}
}

View File

@@ -1,28 +0,0 @@
#[derive(Clone, Debug)]
pub struct SmolLMConfig {
pub hidden_size: usize,
pub intermediate_size: usize,
pub vocab_size: usize,
pub num_hidden_layers: usize,
pub num_attention_heads: usize,
pub num_key_value_heads: usize,
pub rms_norm_eps: f64,
pub rope_theta: f32,
pub max_position_embeddings: usize,
}
impl Default for SmolLMConfig {
fn default() -> Self {
Self {
hidden_size: 576,
intermediate_size: 1536,
vocab_size: 49152,
num_hidden_layers: 30,
num_attention_heads: 9,
num_key_value_heads: 3,
rms_norm_eps: 1e-5,
rope_theta: 10000.0,
max_position_embeddings: 2048,
}
}
}

View File

@@ -1,90 +0,0 @@
use burn::tensor::{backend::Backend, Tensor, TensorData};
use candle_core::safetensors;
use candle_core::Device as CandleDevice;
use burn::module::Param;
use super::model::LlamaModel;
use super::config::SmolLMConfig;
fn load_tensor_2d<B: Backend>(
tensors_map: &std::collections::HashMap<String, candle_core::Tensor>,
name: &str,
device: &B::Device,
shape_out_in: [usize; 2]
) -> Result<Param<Tensor<B, 2>>, String> {
let t = tensors_map.get(name).ok_or_else(|| format!("Puuttuu: {}", name))?;
let t = t.to_dtype(candle_core::DType::F32).unwrap();
let vec = t.flatten_all().unwrap().to_vec1::<f32>().unwrap();
let t_burn = Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(vec, shape_out_in), device);
// transpose from [out, in] to [in, out]
Ok(Param::from_tensor(t_burn.transpose()))
}
fn load_tensor_1d<B: Backend>(
tensors_map: &std::collections::HashMap<String, candle_core::Tensor>,
name: &str,
device: &B::Device,
_shape: [usize; 1]
) -> Result<Param<Tensor<B, 1>>, String> {
let t = tensors_map.get(name).ok_or_else(|| format!("Puuttuu: {}", name))?;
let t = t.to_dtype(candle_core::DType::F32).unwrap();
let vec = t.flatten_all().unwrap().to_vec1::<f32>().unwrap();
Ok(Param::from_tensor(Tensor::<B, 1>::from_floats(vec.as_slice(), device)))
}
fn load_embed<B: Backend>(
tensors_map: &std::collections::HashMap<String, candle_core::Tensor>,
name: &str,
device: &B::Device,
shape: [usize; 2]
) -> Result<Param<Tensor<B, 2>>, String> {
let t = tensors_map.get(name).ok_or_else(|| format!("Puuttuu: {}", name))?;
let t = t.to_dtype(candle_core::DType::F32).unwrap();
let vec = t.flatten_all().unwrap().to_vec1::<f32>().unwrap();
// Embed ei transponoi samalla tavalla, se pysyy [vocab, hidden]
Ok(Param::from_tensor(Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(vec, shape), device)))
}
pub fn load_safetensors_to_model<B: Backend>(
buffer: &[u8],
config: &SmolLMConfig,
device: &B::Device
) -> Result<LlamaModel<B>, String> {
let mut model = LlamaModel::new(config, device);
let tensors_map = safetensors::load_buffer(buffer, &CandleDevice::Cpu)
.map_err(|e| format!("Virhe Safetensors luennassa: {}", e))?;
// Embeddings
model.embed_tokens = load_embed(&tensors_map, "model.embed_tokens.weight", device, [config.vocab_size, config.hidden_size])?;
model.norm.weight = load_tensor_1d(&tensors_map, "model.norm.weight", device, [config.hidden_size])?;
model.lm_head = load_embed(&tensors_map, "lm_head.weight", device, [config.vocab_size, config.hidden_size]).or_else(|_| {
load_embed(&tensors_map, "model.embed_tokens.weight", device, [config.vocab_size, config.hidden_size])
})?;
let head_dim = config.hidden_size / config.num_attention_heads;
for i in 0..config.num_hidden_layers {
let prefix = format!("model.layers.{}", i);
let layer = &mut model.layers[i];
// Norms
layer.input_layernorm.weight = load_tensor_1d(&tensors_map, &format!("{}.input_layernorm.weight", prefix), device, [config.hidden_size])?;
layer.post_attention_layernorm.weight = load_tensor_1d(&tensors_map, &format!("{}.post_attention_layernorm.weight", prefix), device, [config.hidden_size])?;
// Attention
let num_heads = config.num_attention_heads;
let num_kv_heads = config.num_key_value_heads;
layer.self_attn.q_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.q_proj.weight", prefix), device, [num_heads * head_dim, config.hidden_size])?;
layer.self_attn.k_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.k_proj.weight", prefix), device, [num_kv_heads * head_dim, config.hidden_size])?;
layer.self_attn.v_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.v_proj.weight", prefix), device, [num_kv_heads * head_dim, config.hidden_size])?;
layer.self_attn.o_proj = load_tensor_2d(&tensors_map, &format!("{}.self_attn.o_proj.weight", prefix), device, [config.hidden_size, num_heads * head_dim])?;
// MLP
layer.mlp.gate_proj = load_tensor_2d(&tensors_map, &format!("{}.mlp.gate_proj.weight", prefix), device, [config.intermediate_size, config.hidden_size])?;
layer.mlp.up_proj = load_tensor_2d(&tensors_map, &format!("{}.mlp.up_proj.weight", prefix), device, [config.intermediate_size, config.hidden_size])?;
layer.mlp.down_proj = load_tensor_2d(&tensors_map, &format!("{}.mlp.down_proj.weight", prefix), device, [config.hidden_size, config.intermediate_size])?;
}
Ok(model)
}

View File

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

View File

@@ -1,96 +0,0 @@
use burn::module::{Module, Param};
use burn::tensor::{backend::Backend, Tensor, Int};
use super::modules::{RmsNorm, Mlp};
use super::attention::{Attention, KVCache};
use super::config::SmolLMConfig;
#[derive(Module, Debug)]
pub struct LlamaBlock<B: Backend> {
pub self_attn: Attention<B>,
pub mlp: Mlp<B>,
pub input_layernorm: RmsNorm<B>,
pub post_attention_layernorm: RmsNorm<B>,
}
impl<B: Backend> LlamaBlock<B> {
pub fn new(config: &SmolLMConfig, device: &B::Device) -> Self {
Self {
self_attn: Attention::new(config, device),
mlp: Mlp::new(config.hidden_size, config.intermediate_size, device),
input_layernorm: RmsNorm::new(config.hidden_size, config.rms_norm_eps, device),
post_attention_layernorm: RmsNorm::new(config.hidden_size, config.rms_norm_eps, device),
}
}
pub fn forward(
&self,
x: Tensor<B, 3>,
offset: usize,
cache: Option<KVCache<B>>
) -> (Tensor<B, 3>, KVCache<B>) {
let residual = x.clone();
let x_norm = self.input_layernorm.forward(x);
let (attn_out, new_cache) = self.self_attn.forward(x_norm, offset, cache);
let x = residual + attn_out;
let residual = x.clone();
let x_norm = self.post_attention_layernorm.forward(x);
let mlp_out = self.mlp.forward(x_norm);
let x = residual + mlp_out;
(x, new_cache)
}
}
#[derive(Module, Debug)]
pub struct LlamaModel<B: Backend> {
pub embed_tokens: Param<Tensor<B, 2>>,
pub layers: Vec<LlamaBlock<B>>,
pub norm: RmsNorm<B>,
pub lm_head: Param<Tensor<B, 2>>, // For tie_word_embeddings this can point to embed_tokens
}
impl<B: Backend> LlamaModel<B> {
pub fn new(config: &SmolLMConfig, device: &B::Device) -> Self {
let embed = Tensor::zeros([config.vocab_size, config.hidden_size], device);
let lm_head = Tensor::zeros([config.vocab_size, config.hidden_size], device);
let mut layers = Vec::new();
for _ in 0..config.num_hidden_layers {
layers.push(LlamaBlock::new(config, device));
}
Self {
embed_tokens: Param::from_tensor(embed),
layers,
norm: RmsNorm::new(config.hidden_size, config.rms_norm_eps, device),
lm_head: Param::from_tensor(lm_head),
}
}
pub fn forward(
&self,
input_ids: Tensor<B, 2, Int>,
offset: usize,
caches: &mut Vec<Option<KVCache<B>>>
) -> Tensor<B, 3> {
let [_batch, _seq_len] = input_ids.dims();
let mut x = burn::tensor::module::embedding(self.embed_tokens.val(), input_ids);
for (i, layer) in self.layers.iter().enumerate() {
let cache = caches[i].take();
let (out, new_cache) = layer.forward(x, offset, cache);
x = out;
caches[i] = Some(new_cache);
}
x = self.norm.forward(x);
// Matmul with lm_head (or embed_tokens if tied) to get logits
// Notice: lm_head is typically [vocab_size, hidden_size] in HF, so we swap dims
x.matmul(self.lm_head.val().swap_dims(0, 1).unsqueeze())
}
}

View File

@@ -1,59 +0,0 @@
use burn::module::{Module, Param};
use burn::tensor::{backend::Backend, Tensor};
#[derive(Module, Debug)]
pub struct RmsNorm<B: Backend> {
pub weight: Param<Tensor<B, 1>>,
epsilon: f64,
}
impl<B: Backend> RmsNorm<B> {
pub fn new(size: usize, epsilon: f64, device: &B::Device) -> Self {
let weight = Param::from_tensor(Tensor::ones([size], device));
Self { weight, epsilon }
}
pub fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
// x: [batch, seq_len, dim]
// RMSNorm: x * weight / sqrt(mean(x^2) + eps)
let x_sq = x.clone().powf_scalar(2.0);
// mean over last dim, keeping dims for broadcast
let [b, s, d] = x_sq.dims();
let variance = x_sq.sum_dim(2).div_scalar(d as f32);
let norm = x.div(variance.add_scalar(self.epsilon).sqrt());
let w = self.weight.val().unsqueeze::<2>().unsqueeze::<3>().reshape([1, 1, d]);
norm * w
}
}
#[derive(Module, Debug)]
pub struct Mlp<B: Backend> {
pub gate_proj: Param<Tensor<B, 2>>, // [in, intermediate]
pub up_proj: Param<Tensor<B, 2>>, // [in, intermediate]
pub down_proj: Param<Tensor<B, 2>>, // [intermediate, out]
}
impl<B: Backend> Mlp<B> {
pub fn new(hidden_size: usize, intermediate_size: usize, device: &B::Device) -> Self {
Self {
gate_proj: Param::from_tensor(Tensor::zeros([hidden_size, intermediate_size], device)),
up_proj: Param::from_tensor(Tensor::zeros([hidden_size, intermediate_size], device)),
down_proj: Param::from_tensor(Tensor::zeros([intermediate_size, hidden_size], device)),
}
}
pub fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
// x: [batch, seq, hidden]
// gate = x @ gate_proj -> [batch, seq, intermediate]
let gate = x.clone().matmul(self.gate_proj.val().unsqueeze());
let up = x.matmul(self.up_proj.val().unsqueeze());
// SiLU(gate) * up
let silu = gate.clone() * burn::tensor::activation::sigmoid(gate);
let intermediate = silu * up;
// intermediate @ down_proj -> [batch, seq, hidden]
intermediate.matmul(self.down_proj.val().unsqueeze())
}
}

View File

@@ -1,59 +0,0 @@
use burn::module::Module;
use burn::tensor::{backend::Backend, Tensor};
#[derive(Module, Debug)]
pub struct RoPE<B: Backend> {
cos_cache: Tensor<B, 2>,
sin_cache: Tensor<B, 2>,
}
impl<B: Backend> RoPE<B> {
pub fn new(head_dim: usize, max_seq_len: usize, theta: f32, device: &B::Device) -> Self {
// (head_dim / 2) values
let half_dim = head_dim / 2;
let inv_freq: Vec<f32> = (0..half_dim)
.map(|i| 1.0 / theta.powf((2 * i) as f32 / head_dim as f32))
.collect();
let inv_freq = Tensor::<B, 1>::from_floats(inv_freq.as_slice(), device).unsqueeze::<2>();
let t_floats: Vec<f32> = (0..max_seq_len).map(|v| v as f32).collect();
let t = Tensor::<B, 1>::from_floats(t_floats.as_slice(), device).unsqueeze::<2>().transpose();
// t shape: [max_seq_len, 1]
// inv_freq shape: [1, half_dim]
// freqs shape: [max_seq_len, half_dim]
let freqs = t.matmul(inv_freq);
let cos_cache = freqs.clone().cos();
let sin_cache = freqs.sin();
Self {
cos_cache,
sin_cache,
}
}
pub fn forward(&self, x: Tensor<B, 4>, offset: usize) -> Tensor<B, 4> {
let [batch, heads, seq_len, head_dim] = x.dims();
let half_dim = head_dim / 2;
// x shape: [batch, heads, seq_len, head_dim]
// valitaan viipaleet (x1 ja x2) jotta saadaan pyöritettyä rotaatiot
let x1 = x.clone().slice([0..batch, 0..heads, 0..seq_len, 0..half_dim]);
let x2 = x.clone().slice([0..batch, 0..heads, 0..seq_len, half_dim..head_dim]);
// haetaan vastaava seq offsetista alkaen
let cos = self.cos_cache.clone().slice([offset..offset+seq_len, 0..half_dim])
.unsqueeze::<4>() // [seq, half_dim, 1]
.reshape([1, 1, seq_len, half_dim]);
let sin = self.sin_cache.clone().slice([offset..offset+seq_len, 0..half_dim])
.reshape([1, 1, seq_len, half_dim]);
// x1 * cos - x2 * sin
let o1 = x1.clone().mul(cos.clone()) - x2.clone().mul(sin.clone());
// x2 * cos + x1 * sin
let o2 = x2.mul(cos) + x1.mul(sin);
Tensor::cat(vec![o1, o2], 3)
}
}

View File

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

View File

@@ -1,36 +0,0 @@
use candle_core::{Device, Tensor, DType};
use candle_nn::VarBuilder;
use candle_transformers::models::phi3::{Config as Phi3Config, Model as Phi3Model};
use wasm_bindgen::JsCast;
use std::cell::RefCell;
use std::rc::Rc;
use web_sys::WebSocket;
use crate::storage;
macro_rules! console_log {
($($t:tt)*) => (web_sys::console::log_1(&format_args!($($t)*).to_string().into()))
}
const MODEL_URL: &str = "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct/resolve/main/model.safetensors.index.json";
const TOKENIZER_URL: &str = "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct/resolve/main/tokenizer.json";
// Phi-3 Mini on iso (7.6 GB) — käytetään kvantisoidumpaa versiota myöhemmin
// Tällä hetkellä: placeholder joka raportoi koon ja jättää inferenssin väliin
pub async fn run_phi3_inference(prompt: String, ws: Rc<RefCell<WebSocket>>) {
console_log!("[Phi-3] Phi-3 Mini 3.8B on liian suuri selaimessa ajettavaksi (~7.6 GB).");
console_log!("[Phi-3] Käytä SmolLM 135M tai Qwen2.5 0.5B selaininferenssiin.");
console_log!("[Phi-3] Phi-3 tuetaan native-node:lla (Docker + GPU).");
let done = serde_json::json!({
"type": "llm_done",
"prompt": prompt,
"model": "Phi-3-Mini (ei tuettu selaimessa)",
"response": "Phi-3 Mini 3.8B on liian suuri selaimessa ajettavaksi. Käytä SmolLM 135M tai Qwen2.5 0.5B.",
"tokens_generated": 0,
"duration_ms": 0,
"tokens_per_sec": 0,
"load_time_ms": 0,
});
let _ = ws.borrow().send_with_str(&done.to_string());
}

View File

@@ -1,232 +0,0 @@
use candle_core::{Device, Tensor, DType};
use candle_nn::VarBuilder;
use candle_transformers::models::llama::{Llama, LlamaConfig, LlamaEosToks, Cache};
// LogitsProcessor poistettu — käytetään greedy samplingia (argmax) Wasm-yhteensopivuuden vuoksi
use wasm_bindgen::JsCast;
use std::cell::RefCell;
use std::rc::Rc;
use web_sys::WebSocket;
use crate::storage;
macro_rules! console_log {
($($t:tt)*) => (web_sys::console::log_1(&format_args!($($t)*).to_string().into()))
}
const MODEL_URL: &str = "https://huggingface.co/HuggingFaceTB/SmolLM-135M-Instruct/resolve/main/model.safetensors";
const TOKENIZER_URL: &str = "https://huggingface.co/HuggingFaceTB/SmolLM-135M-Instruct/resolve/main/tokenizer.json";
/// Lataa tiedosto HuggingFacesta streaming-latauksella (progress-ilmoitukset) ja tallentaa IndexedDB:hen
async fn ensure_cached(key: &str, url: &str, ws: &Rc<RefCell<WebSocket>>) -> Result<Vec<u8>, String> {
if let Ok(Some(bytes)) = storage::load_from_idb(key).await {
console_log!("[SmolLM] {} löytyi välimuistista ({} MB)", key, bytes.len() / 1024 / 1024);
send_progress(ws, key, 100, bytes.len(), bytes.len());
return Ok(bytes);
}
console_log!("[SmolLM] Ladataan {}...", key);
send_progress(ws, key, 0, 0, 0);
// Fetch API:lla saadaan Content-Length ja streaming-luku
let resp = crate::worker_fetch(url).await?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
// Kokonaiskoko Content-Length-headerista
let total_size: usize = resp.headers()
.get("content-length").ok().flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let body = resp.body().ok_or("Ei bodyä")?;
let reader = body.get_reader();
let reader: web_sys::ReadableStreamDefaultReader = reader.dyn_into().map_err(|_| "Ei ReadableStreamDefaultReader".to_string())?;
let mut data: Vec<u8> = Vec::with_capacity(total_size);
let mut last_pct: u32 = 0;
loop {
let chunk = wasm_bindgen_futures::JsFuture::from(reader.read())
.await.map_err(|e| format!("Luku epäonnistui: {:?}", e))?;
let done = js_sys::Reflect::get(&chunk, &"done".into())
.map_err(|_| "done-kenttä puuttuu".to_string())?
.as_bool().unwrap_or(true);
if done { break; }
let value = js_sys::Reflect::get(&chunk, &"value".into())
.map_err(|_| "value-kenttä puuttuu".to_string())?;
let array = js_sys::Uint8Array::new(&value);
let mut buf = vec![0u8; array.length() as usize];
array.copy_to(&mut buf);
data.extend_from_slice(&buf);
// Progress-päivitys (joka 5%)
if total_size > 0 {
let pct = ((data.len() as f64 / total_size as f64) * 100.0) as u32;
if pct >= last_pct + 5 || pct == 100 {
last_pct = pct;
console_log!("[SmolLM] {} lataus: {}% ({}/{} MB)", key, pct, data.len() / 1024 / 1024, total_size / 1024 / 1024);
send_progress(ws, key, pct, data.len(), total_size);
}
}
}
console_log!("[SmolLM] Tallennetaan {} ({} MB) IndexedDB:hen...", key, data.len() / 1024 / 1024);
let _ = storage::save_to_idb(key, &data).await;
console_log!("[SmolLM] {} tallennettu!", key);
send_progress(ws, key, 100, data.len(), data.len());
Ok(data)
}
fn send_progress(ws: &Rc<RefCell<WebSocket>>, file: &str, pct: u32, loaded: usize, total: usize) {
let msg = serde_json::json!({
"type": "download_progress",
"file": file,
"pct": pct,
"loaded_mb": loaded / 1024 / 1024,
"total_mb": total / 1024 / 1024,
});
let _ = ws.borrow().send_with_str(&msg.to_string());
}
/// Lataa malli ja tokenizer, suorita inferenssi ja streamaa tokenit hubille
pub async fn run_smollm_inference(prompt: String, ws: Rc<RefCell<WebSocket>>) {
// performance via crate::perf_now()
// 1. Lataa tokenizer
let tok_bytes = match ensure_cached("smollm-tokenizer.json", TOKENIZER_URL, &ws).await {
Ok(b) => b,
Err(e) => { console_log!("[SmolLM] Tokenizer-virhe: {}", e); return; }
};
let tokenizer = match tokenizers::Tokenizer::from_bytes(&tok_bytes) {
Ok(t) => t,
Err(e) => { console_log!("[SmolLM] Tokenizer-parsinta epäonnistui: {}", e); return; }
};
// 2. Lataa mallin painot
let model_bytes = match ensure_cached("smollm-model.safetensors", MODEL_URL, &ws).await {
Ok(b) => b,
Err(e) => { console_log!("[SmolLM] Malli-virhe: {}", e); return; }
};
// Burn 0.14 wgpu ei yhteensopiva nykyisten selainten kanssa (maxInterStageShaderComponents)
// Burn 0.21-pre.2 cubecl-runtime ei käänny Wasmille (println! puuttuu)
// → NdArray kunnes Burn 0.21 stable + Wasm-tuki
console_log!("[SmolLM] Burn NdArray (CPU) inferenssi...");
run_burn_inference::<burn::backend::NdArray>(prompt, model_bytes, tokenizer, ws).await;
}
async fn run_burn_inference<B: burn::tensor::backend::Backend>(
prompt: String,
model_bytes: Vec<u8>,
tokenizer: tokenizers::Tokenizer,
ws: Rc<RefCell<WebSocket>>,
) {
let start_load = crate::perf_now();
let device = Default::default();
let config = crate::burn_smollm::config::SmolLMConfig::default();
console_log!("[SmolLM] Injektoidaan Safetensors -> Burn Params...");
let model = match crate::burn_smollm::loader::load_safetensors_to_model::<B>(&model_bytes, &config, &device) {
Ok(m) => m,
Err(e) => { console_log!("[SmolLM] Lataus epäonnistui: {}", e); return; }
};
let load_time = crate::perf_now() - start_load;
console_log!("[SmolLM] Burn-malli ladattu ({:.0}ms). Generoidaan...", load_time);
let formatted_prompt = format!("<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", prompt);
let encoding = match tokenizer.encode(formatted_prompt.as_str(), true) {
Ok(e) => e,
Err(e) => { console_log!("[SmolLM] Tokenisointivirhe: {}", e); return; }
};
let mut input_ids: Vec<u32> = encoding.get_ids().to_vec();
let input_len = input_ids.len();
console_log!("[SmolLM] Syöte: {} tokenia", input_len);
let start_gen = crate::perf_now();
let max_new_tokens = 32;
let mut generated_text = String::new();
let mut tokens_generated: usize = 0;
// KV-välimuistin taulukko kerroksittain
let mut caches: Vec<Option<crate::burn_smollm::attention::KVCache<B>>> = vec![None; config.num_hidden_layers];
let mut current_offset = 0;
// Prefill: yksitellen, vältetään future token leakage koska ei causal maskia
let input_ids_i32: Vec<i32> = input_ids.iter().map(|&x| x as i32).collect();
let mut last_logits = None;
for &id in &input_ids_i32 {
let input_tensor = burn::tensor::Tensor::<B, 1, burn::tensor::Int>::from_data(
burn::tensor::TensorData::from([id]),
&device
).unsqueeze::<2>(); // [1, 1]
last_logits = Some(model.forward(input_tensor, current_offset, &mut caches));
current_offset += 1;
}
let mut logits = last_logits.unwrap();
// Argmax sämpläys
let next_token_tensor = logits.clone().argmax(2);
let mut next_token: u32 = next_token_tensor.into_scalar().to_string().parse().unwrap_or(2); // Yksinkertainen cast koska int scalar
if next_token != 2 {
if let Ok(text) = tokenizer.decode(&[next_token], true) {
generated_text.push_str(&text);
let chunk = serde_json::json!({ "type": "llm_chunk", "token": text, "prompt": prompt, "model": "SmolLM-135M (WebGPU)" });
let _ = ws.borrow().send_with_str(&chunk.to_string());
}
tokens_generated += 1;
}
// Autoregressiivinen luuppi
for _ in 1..max_new_tokens {
if next_token == 2 { break; }
let mut input_tensor = burn::tensor::Tensor::<B, 1, burn::tensor::Int>::from_data(
burn::tensor::TensorData::from([next_token as i32]),
&device
).unsqueeze::<2>();
logits = model.forward(input_tensor, current_offset, &mut caches);
current_offset += 1;
let next_token_tensor = logits.argmax(2);
next_token = next_token_tensor.into_scalar().to_string().parse().unwrap_or(2);
if next_token == 2 { break; }
if let Ok(text) = tokenizer.decode(&[next_token], true) {
generated_text.push_str(&text);
let chunk = serde_json::json!({ "type": "llm_chunk", "token": text, "prompt": prompt, "model": "SmolLM-135M (WebGPU)" });
let _ = ws.borrow().send_with_str(&chunk.to_string());
}
tokens_generated += 1;
}
let gen_time = crate::perf_now() - start_gen;
let tokens_per_sec = if gen_time > 0.0 { (tokens_generated as f64 / gen_time) * 1000.0 } else { 0.0 };
let done = serde_json::json!({
"type": "llm_done",
"prompt": prompt,
"model": "SmolLM-135M-Instruct (WebGPU)",
"response": generated_text,
"tokens_generated": tokens_generated,
"duration_ms": (gen_time * 100.0).round() / 100.0,
"tokens_per_sec": (tokens_per_sec * 10.0).round() / 10.0,
"load_time_ms": (load_time * 100.0).round() / 100.0,
});
let _ = ws.borrow().send_with_str(&done.to_string());
}

Binary file not shown.

83
projektit/projekti1.md Normal file
View File

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