Compare commits
30 Commits
revolution
...
7fcc97f525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fcc97f525 | ||
|
|
7ce990b42a | ||
|
|
dc71829430 | ||
|
|
5d4a553520 | ||
|
|
5e82c798b1 | ||
|
|
5f147b774f | ||
|
|
4983217ee0 | ||
|
|
27c33e41c3 | ||
|
|
2b33980be4 | ||
|
|
8995bcef30 | ||
|
|
2f140c8a15 | ||
|
|
094b183c17 | ||
|
|
a91b9539b3 | ||
|
|
6e2f85daa8 | ||
|
|
466e61d730 | ||
|
|
5f00582053 | ||
|
|
e272b0d124 | ||
|
|
d3affb3a09 | ||
|
|
1377e72f78 | ||
|
|
403f35efdc | ||
|
|
ce0ccbddd3 | ||
|
|
80806498e0 | ||
|
|
660e80c2bc | ||
|
|
591cfcb04b | ||
|
|
3cda57f0bc | ||
|
|
23e7b92d03 | ||
|
|
9f58febe21 | ||
|
|
b1de0d37f7 | ||
|
|
4ff626ab88 | ||
|
|
a45616046d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
131
kipina-node
Executable 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
BIN
kipina-node-bin
Executable file
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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-*
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
56
network-poc/deploy-local.sh
Executable 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
59
network-poc/deploy-remote.sh
Executable 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 ==="
|
||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
1
network-poc/frontend/public/download/.build-hash
Normal file
1
network-poc/frontend/public/download/.build-hash
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dirty-3e9cdd70c60dadfb970cee47ebbd912c
|
||||||
BIN
network-poc/frontend/public/download/kipina-node-linux-arm64
Executable file
BIN
network-poc/frontend/public/download/kipina-node-linux-arm64
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
network-poc/frontend/public/download/kipina-node-windows-x86_64.exe
Executable file
BIN
network-poc/frontend/public/download/kipina-node-windows-x86_64.exe
Executable file
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
33
network-poc/frontend/public/templates/data-analytics.json
Normal file
33
network-poc/frontend/public/templates/data-analytics.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 "https://kipina.studio/kipina-node?v=$(date +%s)" -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>
|
||||||
|
|||||||
@@ -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 <malli> "prompti" — aja tehtävä', '#a5d6ff');
|
termLog(' kpn run <malli> "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
34
network-poc/hub-local.log
Normal 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`
|
||||||
|
[2m2026-04-12T04:56:09.723604Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Tietokanta alustettu
|
||||||
|
[2m2026-04-12T04:56:09.725088Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Kipinä Agent Hub v0.3.1 käynnistyy osoitteessa http://localhost:3000
|
||||||
|
[2m2026-04-12T04:56:18.997935Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 1 yhdistyi osoitteesta 127.0.0.1
|
||||||
|
[2m2026-04-12T04:56:19.027478Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 1 (natiivi) | 127.0.0.1 | Mac | Darwin 26.3.1 | 12 ydintä | 32768 MB RAM | varaus: 4 GB
|
||||||
|
[2m2026-04-12T04:56:19.029931Z[0m [32m INFO[0m [2mhub[0m[2m:[0m GPU 0: Apple M2 Max | VRAM: 0/24576 MB | 0°C | 0%
|
||||||
|
[2m2026-04-12T04:56:31.260470Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 2 yhdistyi osoitteesta 127.0.0.1
|
||||||
|
[2m2026-04-12T04:56:31.281759Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 2 (selain) | 127.0.0.1 | MacIntel | 11 ydintä | ~8 GB RAM | GPU: ei GPU:ta | tehtävä: viewer | varaus: 0 GB
|
||||||
|
[2m2026-04-12T04:56:31.283313Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Reititettiin API-pyyntö solmulle 1 (Malli: qwen-coder)
|
||||||
|
|
||||||
|
[35m━━━ Solmu 1 ━━━ qwen2.5-coder:7b-instruct-q4_K_M (Ollama) ━━━[0m
|
||||||
|
Prompt: [33m"ping"[0m
|
||||||
|
Vastaus: [32mPong! How can I assist you today?[0m
|
||||||
|
11 tokenia | 4502ms | [36m56.3 tok/s[0m
|
||||||
|
[2m2026-04-12T04:56:36.419646Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 2 (127.0.0.1) poistui verkosta.
|
||||||
|
[2m2026-04-12T04:56:36.433155Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 3 yhdistyi osoitteesta 127.0.0.1
|
||||||
|
[2m2026-04-12T04:56:36.445127Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 3 (selain) | 127.0.0.1 | MacIntel | 11 ydintä | ~8 GB RAM | GPU: ei GPU:ta | tehtävä: viewer | varaus: 0 GB
|
||||||
|
[2m2026-04-12T04:56:36.445818Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Reititettiin API-pyyntö solmulle 1 (Malli: qwen-coder)
|
||||||
|
|
||||||
|
[35m━━━ Solmu 1 ━━━ qwen2.5-coder:7b-instruct-q4_K_M (Ollama) ━━━[0m
|
||||||
|
Prompt: [33m"ping"[0m
|
||||||
|
Vastaus: [32mPong! How can I assist you today? If you have any questions or need information on a specific topic, feel free to let me know.[0m
|
||||||
|
31 tokenia | 679ms | [36m57.5 tok/s[0m
|
||||||
|
[2m2026-04-12T04:56:39.466711Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 3 (127.0.0.1) poistui verkosta.
|
||||||
|
[2m2026-04-12T04:56:43.881216Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 4 yhdistyi osoitteesta 127.0.0.1
|
||||||
|
[2m2026-04-12T04:56:43.894385Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 4 (selain) | 127.0.0.1 | MacIntel | 3 ydintä | ~16 GB RAM | GPU: ei GPU:ta | tehtävä: viewer | varaus: 0 GB
|
||||||
|
[2m2026-04-12T04:56:43.894960Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Reititettiin API-pyyntö solmulle 1 (Malli: qwen-coder)
|
||||||
|
|
||||||
|
[35m━━━ Solmu 1 ━━━ qwen2.5-coder:7b-instruct-q4_K_M (Ollama) ━━━[0m
|
||||||
|
Prompt: [33m"ping"[0m
|
||||||
|
Vastaus: [32mPong! How can I assist you today?[0m
|
||||||
|
11 tokenia | 333ms | [36m58.7 tok/s[0m
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
67
network-poc/native-node/src/tui.rs
Normal file
67
network-poc/native-node/src/tui.rs
Normal 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())
|
||||||
|
}
|
||||||
296
network-poc/native-node/src/tui_dashboard.rs
Normal file
296
network-poc/native-node/src/tui_dashboard.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod attention;
|
|
||||||
pub mod config;
|
|
||||||
pub mod loader;
|
|
||||||
pub mod model;
|
|
||||||
pub mod modules;
|
|
||||||
pub mod rope;
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -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
83
projektit/projekti1.md
Normal 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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ää!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Reference in New Issue
Block a user