Compare commits
48 Commits
6e2f85daa8
...
projekti1
| Author | SHA1 | Date | |
|---|---|---|---|
| 20cea8f268 | |||
| 38a18c555b | |||
| 8138e41aa1 | |||
| 6ee5bdf960 | |||
| cf3bf54bf8 | |||
| 56f21a96c9 | |||
| 763b93396c | |||
| e09962940a | |||
| 5e44b63b0c | |||
| 0f3881aa02 | |||
| fa85dcc5b3 | |||
| 58d93613f0 | |||
| 66b4435362 | |||
| 3a00de9b8e | |||
| 670141c8c3 | |||
| 59daebbd38 | |||
| 42b71dbf77 | |||
| b88a741f85 | |||
|
|
68c7195d54 | ||
|
|
3d20238eef | ||
|
|
8b8ba01af3 | ||
|
|
a3b95a56e8 | ||
|
|
5b20ebe800 | ||
|
|
ffe9bd6902 | ||
|
|
d27068b11a | ||
|
|
8468724a4c | ||
|
|
6ef71b7e5c | ||
|
|
b2ee8b9031 | ||
|
|
c1a5f8aff5 | ||
|
|
8ee997cb56 | ||
|
|
cd67562a67 | ||
|
|
1f85c03624 | ||
|
|
74a2045def | ||
|
|
9b2b7767b5 | ||
|
|
1718805978 | ||
|
|
7fcc97f525 | ||
|
|
7ce990b42a | ||
|
|
dc71829430 | ||
|
|
5d4a553520 | ||
|
|
5e82c798b1 | ||
|
|
5f147b774f | ||
|
|
4983217ee0 | ||
|
|
27c33e41c3 | ||
|
|
2b33980be4 | ||
|
|
8995bcef30 | ||
|
|
2f140c8a15 | ||
|
|
094b183c17 | ||
|
|
a91b9539b3 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -42,4 +42,7 @@ Cargo.lock
|
||||
*.log
|
||||
|
||||
# Wanha versio
|
||||
temp/
|
||||
temp/
|
||||
|
||||
# Muut
|
||||
zipit/**
|
||||
157
TEMPLATING.md
Normal file
157
TEMPLATING.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Templating — rakennuspalaset koodigeneroinnissa
|
||||
|
||||
## Perusperiaate
|
||||
|
||||
Kielimalli päättää **mitä** rakennetaan (entiteetit, kentät, tyypit, yhteydet).
|
||||
Template-funktiot päättävät **miten** se rakennetaan (importit, engine setup, testikonfiguraatio).
|
||||
|
||||
```
|
||||
Projektikuvaus → LLM → JSON-speksi → Templateit → Koodi → Validointi
|
||||
```
|
||||
|
||||
LLM:n kontribuutio on yksi JSON-rakenne. Kaikki muu on determinististä —
|
||||
sama speksi tuottaa aina saman koodin.
|
||||
|
||||
## Miksi tämä toimii
|
||||
|
||||
Pienen kielimallin (0.5B–7B) vahvuudet ja heikkoudet ovat epäsymmetrisiä:
|
||||
|
||||
| Tehtävä | LLM:n kyky | Ratkaisu |
|
||||
|---------|-----------|----------|
|
||||
| Tunnista entiteetit kuvauksesta | Hyvä | LLM tekee |
|
||||
| Valitse kenttätyypit | Hyvä | LLM tekee |
|
||||
| Muista importit oikein | Huono | Template tekee |
|
||||
| SQLite connect_args | Huono | Template tekee |
|
||||
| Testikonfiguraatio | Huono | Template tekee |
|
||||
| Dockerfile-rakenne | Huono | Template tekee |
|
||||
|
||||
Annetaan mallin tehdä se missä se on hyvä. Hoidetaan loput mekaanisesti.
|
||||
|
||||
## JSON-speksi
|
||||
|
||||
Kielimallin ainoa tuotos on JSON joka kuvaa projektin rakenteen:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_name": "library-app",
|
||||
"entities": [
|
||||
{
|
||||
"name": "Author",
|
||||
"table_name": "authors",
|
||||
"fields": [
|
||||
{"name": "name", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Book",
|
||||
"table_name": "books",
|
||||
"fields": [
|
||||
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null},
|
||||
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false, "default": null}
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
|
||||
],
|
||||
"extra_imports": []
|
||||
}
|
||||
```
|
||||
|
||||
Speksin laatu ratkaisee kaiken. Hyvä speksi → hyvä projekti. Huono speksi →
|
||||
teknisesti toimiva mutta sisällöllisesti väärä projekti.
|
||||
|
||||
## Architect-promptin rooli
|
||||
|
||||
Architect-agentti (JSON-speksin generoija) on kriittisin kohta koko pipelinessa.
|
||||
Sitä ohjataan neljällä keinolla:
|
||||
|
||||
1. **Chain-of-thought** — malli miettii ensin entiteetit, sitten kentät,
|
||||
sitten yhteydet, vasta lopuksi JSON
|
||||
2. **Domain-esimerkit** — Todo, verkkokauppa, blogi — malli näkee miltä
|
||||
hyvä speksi näyttää eri domaineissa
|
||||
3. **Anti-patternit** — turhat ID-kentät, Enum-tyypit, suomenkieliset nimet
|
||||
4. **Yhteyssäännöt** — jokainen `_id`-kenttä tarvitsee relationship-merkinnän
|
||||
|
||||
Isompi malli tässä yhdessä kohdassa parantaisi kaikkien projektien laatua.
|
||||
|
||||
## Templateit
|
||||
|
||||
Jokainen template on funktio joka ottaa speksin ja palauttaa koodia:
|
||||
|
||||
```
|
||||
tmplModels(spec) → models.py (SQLAlchemy, ForeignKey, relationship)
|
||||
tmplSchemas(spec) → schemas.py (Pydantic Create/Response/Detail)
|
||||
tmplMain(spec) → main.py (FastAPI CRUD + nested endpoints + FK-validointi)
|
||||
tmplTests(spec) → test_main.py (pytest + TestClient + helper-funktiot)
|
||||
tmplPyproject(spec) → pyproject.toml (PEP 621)
|
||||
tmplDockerfile() → Dockerfile (uv + non-root user)
|
||||
```
|
||||
|
||||
Templateit generoivat automaattisesti:
|
||||
- ForeignKey-constraintit ja relationship()-määrittelyt
|
||||
- Nested endpointit (`GET /authors/{id}/books/`)
|
||||
- FK-validointi (404 jos parent-entiteettiä ei ole)
|
||||
- Detail-schemat (Book + author-data mukana)
|
||||
- Test-helperit jotka luovat parent-entiteetit ensin
|
||||
- Bad FK -testit (varmistaa että orpo-validointi toimii)
|
||||
|
||||
## Validointi
|
||||
|
||||
Generoitu koodi validoidaan mekaanisesti ennen käyttöä:
|
||||
|
||||
- Syntaksitarkistus (AST parse)
|
||||
- Projektin sisäiset importit (löytyykö nimi lähdetiedostosta)
|
||||
- SQLite connect_args
|
||||
- Relatiiviset importit (kielletty)
|
||||
- Testien rakenne (ei saa kopioida appia)
|
||||
- pyproject.toml (ei poetryä)
|
||||
- Dockerfile (ei poetryä, uv cache -oikeudet)
|
||||
|
||||
Docker-testi ajaa koko projektin: build → pytest → API smoke test.
|
||||
|
||||
## Rajoitukset
|
||||
|
||||
Templateit kattavat rakenteellisesti tunnetut projektit:
|
||||
|
||||
| Stack | Kattavuus |
|
||||
|-------|-----------|
|
||||
| FastAPI + SQLAlchemy CRUD | Toimii hyvin |
|
||||
| Streamlit + DuckDB dashboard | Toimii hyvin |
|
||||
| Muu | Ei templatea → ei toimi |
|
||||
|
||||
**Ei kata:**
|
||||
- Custom business-logiikka (algoritmit, laskenta, ML)
|
||||
- Epätyypilliset arkkitehtuurit (WebSocket, graafit, tapahtumapohjaiset)
|
||||
- Frontend-sovellukset (React, Vue)
|
||||
- Mikä tahansa mitä template ei tunne
|
||||
|
||||
Arvio: templateit kattavat ~20% kaikista mahdollisista projekteista, mutta juuri
|
||||
sen 20% mitä opiskelu- ja prototyyppiympäristöissä tarvitaan useimmin.
|
||||
|
||||
## Laajentaminen
|
||||
|
||||
Uuden stackin lisääminen vaatii:
|
||||
|
||||
1. Uudet template-funktiot (käsityö, ~200–400 riviä per stack)
|
||||
2. JSON-speksin laajennos (uudet kentät jos tarvitaan)
|
||||
3. Validointisäännöt uudelle stackille
|
||||
4. Docker-testikonfiguraatio
|
||||
|
||||
Jokainen template on staattinen — se ei opi eikä sopeudu. Kattavuus kasvaa
|
||||
vain kirjoittamalla lisää templateja.
|
||||
|
||||
## Hybridi: seuraava askel
|
||||
|
||||
Paras lopputulos syntyisi yhdistelmällä:
|
||||
|
||||
```
|
||||
Speksi → Template (runko) → LLM (business-logiikka) → Validointi
|
||||
```
|
||||
|
||||
Template tuottaa toimivan CRUD-pohjan. LLM lisää domain-kohtaisen logiikan
|
||||
pienissä palasissa (yksi funktio kerrallaan). Mekaaninen validointi
|
||||
tarkistaa jokaisen lisäyksen.
|
||||
|
||||
Tämä palauttaa LLM:n epäluotettavuuden takaisin peliin, mutta rajattuna:
|
||||
virheet ovat paikallisia (yksi funktio) eivätkä rakenteellisia (koko projekti).
|
||||
131
kipina-node
Executable file
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) ---
|
||||
FROM node:22-slim AS frontend
|
||||
WORKDIR /app/frontend
|
||||
# Riippuvuudet ensin → cache-kerros (muuttuu harvoin)
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install --silent
|
||||
# Lähdekoodi → muuttuu usein, mutta npm install on cachessa
|
||||
COPY frontend/ .
|
||||
# Cache-buster: git hash pakottaa rebuildin kun koodi muuttuu
|
||||
ARG CACHEBUST=0
|
||||
COPY frontend/src/ ./src/
|
||||
COPY frontend/public/ ./public/
|
||||
COPY frontend/astro.config.mjs frontend/tsconfig.json ./
|
||||
RUN npm run build
|
||||
|
||||
# --- Vaihe 2: Wasm (wasm-pack) ---
|
||||
# Cargo registry cachetetaan mount-cachella, lähdekoodi kopioidaan tuoreena
|
||||
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 curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
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 native-node/Cargo.toml native-node/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
|
||||
ARG CACHEBUST=0
|
||||
COPY node/src node/src
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
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
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
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 native-node/Cargo.toml native-node/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
|
||||
ARG CACHEBUST=0
|
||||
COPY hub/src hub/src
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
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=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
|
||||
ENV STATIC_DIR=/app/frontend/dist
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,63 +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"
|
||||
HASH_FILE="$OUT/.build-hash"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# Tarkistetaan onko native-node muuttunut edellisen buildin jälkeen
|
||||
CURRENT_HASH=$(git -C "$SCRIPT_DIR" log -1 --format=%H -- native-node/ Cargo.toml Cargo.lock)
|
||||
if [ -n "$(git -C "$SCRIPT_DIR" status --porcelain -- native-node/ Cargo.toml Cargo.lock)" ]; then
|
||||
CURRENT_HASH="dirty-$(git -C "$SCRIPT_DIR" status --porcelain | md5sum | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$CURRENT_HASH" ]; then
|
||||
echo "=== Kipinä Node — ei muutoksia, ohitetaan build ==="
|
||||
ls -lh "$OUT"/kipina-node-* 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "=== Kipinä Node — Binary Build ==="
|
||||
|
||||
# macOS ARM (natiivi)
|
||||
echo "[1/4] 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/4] 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 --target-dir target/docker_linux_amd64 && cp target/docker_linux_amd64/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/4] 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 --target-dir target/docker_linux_arm64 && cp target/docker_linux_arm64/release/native-node /app/frontend/public/download/kipina-node-linux-arm64"
|
||||
echo " $(ls -lh "$OUT/kipina-node-linux-arm64" | awk '{print $5}')"
|
||||
|
||||
# Windows x86_64 (Docker + mingw-w64)
|
||||
echo "[4/4] Windows 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 gcc-mingw-w64-x86-64 pkg-config libssl-dev >/dev/null 2>&1 && rustup target add x86_64-pc-windows-gnu && cargo build --release -p native-node --no-default-features --target x86_64-pc-windows-gnu && cp target/x86_64-pc-windows-gnu/release/native-node.exe /app/frontend/public/download/kipina-node-windows-x86_64.exe"
|
||||
echo " $(ls -lh "$OUT/kipina-node-windows-x86_64.exe" | awk '{print $5}')"
|
||||
|
||||
# Tallennetaan onnistuneen buildin hash
|
||||
echo "$CURRENT_HASH" > "$HASH_FILE"
|
||||
|
||||
echo ""
|
||||
echo "=== Binäärit valmiina ==="
|
||||
ls -lh "$OUT"/kipina-node-*
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
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"
|
||||
|
||||
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ä Node - Vain Binäärien Päivitys ==="
|
||||
|
||||
# 1. Käännetään binäärit (hyödyntää korjattua build-binaries.sh cache logiikkaa)
|
||||
"$SCRIPT_DIR/build-binaries.sh"
|
||||
|
||||
# 2. Siirretään binäärit suoraan kohdekoneen hakemistoon ohittaen Docker-imagen täyden rakennuksen
|
||||
echo ""
|
||||
echo "[Vieminen uuteen kohteeseen...]"
|
||||
ssh $SSH_OPTS "$SERVER" "mkdir -p $REMOTE_DIR/frontend/dist/download"
|
||||
scp $SSH_OPTS "$SCRIPT_DIR/frontend/public/download"/kipina-node-* "$SERVER:$REMOTE_DIR/frontend/dist/download/"
|
||||
scp $SSH_OPTS "$SCRIPT_DIR/frontend/public"/kipina-node "$SERVER:$REMOTE_DIR/frontend/dist/"
|
||||
|
||||
# 3. Luvat kuntoon
|
||||
ssh $SSH_OPTS "$SERVER" "chmod +x $REMOTE_DIR/frontend/dist/download/kipina-node-*"
|
||||
|
||||
echo "=== Valmis! Binäärit ovat nyt asennettu livenä ja ladattavissa kipina.studiosta ==="
|
||||
@@ -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,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Deploy + native-node-binäärien käännös (jos muutoksia)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "=== Kipinä Studio Deploy (+ native binäärit) ==="
|
||||
|
||||
# Käännetään native-node-binäärit (ohittaa automaattisesti jos ei muutoksia)
|
||||
"$SCRIPT_DIR/build-binaries.sh"
|
||||
|
||||
# Ajetaan normaali deploy
|
||||
exec "$SCRIPT_DIR/deploy.sh"
|
||||
@@ -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/4] 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/4] 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/4] Ladataan image palvelimella..."
|
||||
ssh $SSH_OPTS $SERVER "gunzip -c /tmp/kipina-agentic.tar.gz | docker load && rm /tmp/kipina-agentic.tar.gz"
|
||||
|
||||
echo "[4/4] 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:-}
|
||||
volumes:
|
||||
- hub_data:/data
|
||||
- ./frontend/dist:/app/frontend/dist:ro
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
|
||||
@@ -230,6 +230,188 @@ mitä luokkia importata.
|
||||
|
||||
---
|
||||
|
||||
## Rakennuspalaset vs. vapaa generointi
|
||||
|
||||
Kielimalli voi generoida koodia kahdella perustavanlaatuisesti eri tavalla.
|
||||
Ymmärtäminen milloin kumpikin toimii on avain luotettavaan koodigenerointi-pipelineen.
|
||||
|
||||
### Tapa 1: Vapaa generointi (naivi)
|
||||
|
||||
LLM generoi jokaisen tiedoston tyhjästä. Prompti kuvaa mitä halutaan,
|
||||
malli tuottaa koko tiedoston — importeista lähtien.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["Prompti"] --> LLM1["LLM: models.py"]
|
||||
LLM1 --> V1{"Validointi"}
|
||||
V1 -->|virhe| LLM1
|
||||
V1 -->|ok| LLM2["LLM: schemas.py"]
|
||||
LLM2 --> V2{"Validointi"}
|
||||
V2 -->|virhe| LLM2
|
||||
V2 -->|ok| LLM3["LLM: main.py"]
|
||||
LLM3 --> V3{"..."}
|
||||
|
||||
style V1 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
|
||||
style V2 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
|
||||
style V3 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
|
||||
```
|
||||
|
||||
**Ongelma:** Pieni malli (0.5B–7B) tekee toistuvia rakenteellisia virheitä:
|
||||
|
||||
| Virhe | Esiintymistiheys | Selitys |
|
||||
|-------|:---:|------|
|
||||
| Puuttuva import | ~60% | `from datetime import date` unohtuu |
|
||||
| SQLite `connect_args` | ~80% | Malli ei muista SQLite-erityisyyttä |
|
||||
| Väärä Enum-käyttö | ~50% | Sekoittaa `sqlalchemy.Enum` ja `enum.Enum` |
|
||||
| Poetry pyproject.toml:ssa | ~40% | Malli suosii Poetryä vaikka ohje sanoo uv |
|
||||
| Testit kopioivat koko appin | ~70% | Malli ei osaa importata, luo uudet reitit |
|
||||
|
||||
Retry-loopilla (virhe → uusi yritys virheviestin kanssa) osa korjautuu,
|
||||
mutta **sama malli toistaa samoja virheitä** koska ne johtuvat harjoitusdatasta.
|
||||
7 tiedoston projekti vaatii 7–14 LLM-kutsua ja 80–120 sekuntia.
|
||||
|
||||
### Tapa 2: Rakennuspalaset (template pipeline)
|
||||
|
||||
LLM:ltä pyydetään **vain JSON-speksi** — entiteetit, kentät ja tyypit.
|
||||
Koodi kootaan mekaanisesti valmiista pohjista joiden rakenne on todistettavasti
|
||||
oikein.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["Projektin kuvaus"] --> LLM["LLM: JSON-speksi"]
|
||||
LLM --> S["{ entities: [...] }"]
|
||||
S --> T1["Template: models.py"]
|
||||
S --> T2["Template: schemas.py"]
|
||||
S --> T3["Template: main.py"]
|
||||
S --> T4["Template: test_main.py"]
|
||||
S --> T5["Template: Dockerfile"]
|
||||
T1 & T2 & T3 & T4 & T5 --> D["Docker build + pytest"]
|
||||
|
||||
style LLM fill:#1a1e2e,stroke:#d29922,color:#c9d1d9
|
||||
style S fill:#1a1e2e,stroke:#3fb950,color:#c9d1d9
|
||||
style D fill:#1a1e2e,stroke:#58a6ff,color:#c9d1d9
|
||||
```
|
||||
|
||||
**Idea:** Malli on hyvä päättämään *mitä* (entiteetit, kentät), mutta huono
|
||||
muistamaan *miten* (importit, engine setup, testikonfiguraatio). Annetaan
|
||||
mallin tehdä se missä se on hyvä, ja hoidetaan loput mekaanisesti.
|
||||
|
||||
### LLM:n ainoa tehtävä
|
||||
|
||||
Malli tuottaa JSON-rakenteen kuten:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_name": "todo-app",
|
||||
"entities": [
|
||||
{
|
||||
"name": "Todo",
|
||||
"table_name": "todos",
|
||||
"fields": [
|
||||
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false},
|
||||
{"name": "due_date", "sa_type": "Date", "py_type": "date | None", "nullable": true},
|
||||
{"name": "status", "sa_type": "String(20)", "py_type": "str", "default": "pending"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extra_imports": ["from datetime import date"]
|
||||
}
|
||||
```
|
||||
|
||||
Tämä on yksinkertainen tehtävä jossa pienikin malli onnistuu luotettavasti:
|
||||
entiteettien tunnistus projektin kuvauksesta ja kenttätyyppien valinta.
|
||||
|
||||
Speksi sisältää myös **taulujen väliset yhteydet** (relationships):
|
||||
|
||||
```json
|
||||
{
|
||||
"entities": [
|
||||
{"name": "Author", "table_name": "authors", "fields": [...]},
|
||||
{"name": "Book", "table_name": "books", "fields": [
|
||||
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false},
|
||||
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false}
|
||||
]}
|
||||
],
|
||||
"relationships": [
|
||||
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Templateit generoivat yhteyksistä automaattisesti:
|
||||
- `ForeignKey('authors.id')` models.py:hin
|
||||
- `relationship("Book", back_populates="author")` molempiin suuntiin
|
||||
- `BookDetail`-schema jossa author-data mukana
|
||||
- `GET /authors/{id}/books/` nested endpoint
|
||||
- FK-validointi: 404 jos parent-entiteettiä ei ole
|
||||
|
||||
### Architect-agentti: speksin laatu ratkaisee
|
||||
|
||||
Arkkitehti on **kriittisin agentti** koko pipelinessa. Jos speksi on hyvä
|
||||
(oikeat taulut, kentät, yhteydet), kaikki muu seuraa automaattisesti.
|
||||
Jos speksi on huono, templateitkaan eivät pelasta.
|
||||
|
||||
Arkkitehtia ohjataan:
|
||||
1. **Chain-of-thought**: "Mieti ensin taulut, sitten kentät, sitten yhteydet"
|
||||
2. **Domain-esimerkit**: Todo, verkkokauppa, blogi — malli näkee miltä hyvä speksi näyttää
|
||||
3. **Anti-patternit**: "Ei turhia ID-kenttiä, ei Enumeita, ei suomenkielisiä nimiä koodissa"
|
||||
4. **Yhteyssäännöt**: "Jokainen `_id`-kenttä tarvitsee vastaavan relationship-merkinnän"
|
||||
|
||||
Isompi malli (tai API) tässä yhdessä kohdassa parantaa kaikkien projektien laatua
|
||||
koska speksi on ainoa paikka jossa LLM:n ymmärrys vaikuttaa.
|
||||
|
||||
### Template täyttää loput
|
||||
|
||||
Jokainen template on kuin madlib — aukot täytetään speksin datalla:
|
||||
|
||||
**models.py template (yksinkertaistettu):**
|
||||
```python
|
||||
from sqlalchemy import create_engine, Column, Integer, {sa_types}, ForeignKey
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
# ... aina samat importit, engine setup, SessionLocal ...
|
||||
|
||||
class {entity.name}(Base):
|
||||
__tablename__ = "{entity.table_name}"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
{field.name} = Column({field.sa_type}, nullable={field.nullable})
|
||||
# FK-kentät: ForeignKey + relationship automaattisesti
|
||||
{fk_field} = Column(Integer, ForeignKey('{parent_table}.id'))
|
||||
{parent_lower} = relationship("{Parent}", back_populates="{children}")
|
||||
```
|
||||
|
||||
Tulos: importit ovat aina oikein, `connect_args` on aina mukana,
|
||||
taulujen yhteydet generoituvat oikein, testit importoivat `main.py`:stä eivätkä kopioi sitä.
|
||||
|
||||
### Vertailu: mittaustulokset
|
||||
|
||||
| | Vapaa generointi | Rakennuspalaset |
|
||||
|---|:---:|:---:|
|
||||
| LLM-kutsuja | 7–14 | **3** (speksi + requirements + README) |
|
||||
| Aika | 80–120s | **~25s** |
|
||||
| Syntaksi OK | ~70% | **100%** |
|
||||
| Docker build | vaihteleva | **100%** |
|
||||
| Pytest läpi | 0% | **100%** |
|
||||
| API toimii | ~30% | **100%** |
|
||||
| Taulujen yhteydet (FK) | ei koskaan | **100%** |
|
||||
| Nested endpointit | ei koskaan | **automaattisesti** |
|
||||
|
||||
### Milloin kumpikin toimii
|
||||
|
||||
**Rakennuspalaset** kun:
|
||||
- Projektin rakenne on tunnettu (FastAPI + SQLAlchemy CRUD)
|
||||
- Laatu ja luotettavuus ovat tärkeitä
|
||||
- Malli on pieni (0.5B–7B)
|
||||
|
||||
**Vapaa generointi** kun:
|
||||
- Projektin rakenne on epätavallinen
|
||||
- Tarvitaan custom-logiikkaa jota template ei kata
|
||||
- Malli on riittävän iso (>70B tai pilvi-API)
|
||||
|
||||
Paras lopputulos syntyy yhdistelmällä: **rakennuspalaset perusrakenteelle,
|
||||
vapaa generointi business-logiikalle**.
|
||||
|
||||
---
|
||||
|
||||
## Laadun parantaminen
|
||||
|
||||
### 1. Isompi malli (suurin vaikutus)
|
||||
|
||||
@@ -1 +1 @@
|
||||
403f35efdcdf6317309f2e6b450c0ea257550b6a
|
||||
cf3bf54
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
network-poc/frontend/public/forge_hero.webp
Normal file
BIN
network-poc/frontend/public/forge_hero.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
network-poc/frontend/public/gecko_hero.webp
Normal file
BIN
network-poc/frontend/public/gecko_hero.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -99,23 +99,27 @@ if [ -n "$KIPINA_MODEL" ]; then
|
||||
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
|
||||
fi
|
||||
|
||||
# Lataa binääri
|
||||
# Binäärin automaattinen päivitys — vertaa build-hashia palvelimeen
|
||||
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
|
||||
HASH_PATH="./kipina-node-bin.hash"
|
||||
|
||||
if [ ! -f "$BIN_PATH" ]; then
|
||||
echo " Ladataan tuorein $BINARY..."
|
||||
REMOTE_HASH=$(curl -sSL "$BASE_URL/.build-hash?v=$(date +%s)" 2>/dev/null | tr -d '[:space:]')
|
||||
LOCAL_HASH=""
|
||||
[ -f "$HASH_PATH" ] && LOCAL_HASH=$(cat "$HASH_PATH" | tr -d '[:space:]')
|
||||
|
||||
if [ -f "$BIN_PATH" ] && [ -n "$REMOTE_HASH" ] && [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
|
||||
echo " ✓ Binääri ajan tasalla (versio: $LOCAL_HASH)"
|
||||
else
|
||||
if [ -f "$BIN_PATH" ]; then
|
||||
echo " ↻ Uusi versio saatavilla ($LOCAL_HASH → $REMOTE_HASH)"
|
||||
else
|
||||
echo " Ladataan $BINARY..."
|
||||
fi
|
||||
rm -f "$BIN_PATH"
|
||||
curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
|
||||
chmod +x "$BIN_PATH"
|
||||
echo "$REMOTE_HASH" > "$HASH_PATH"
|
||||
echo " ✓ Päivitetty versioon $REMOTE_HASH"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
BIN
network-poc/frontend/public/serpent_hero.webp
Normal file
BIN
network-poc/frontend/public/serpent_hero.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
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",
|
||||
"description": "REST API with SQLite database",
|
||||
"keywords": ["api", "rest", "crud", "endpoint", "fastapi", "web", "backend", "server", "database", "sqlite"],
|
||||
"files": {
|
||||
"models.py": {
|
||||
"description": "SQLAlchemy models, engine, and session",
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
<!-- Agenttigalleria + konfigurointipaneeli -->
|
||||
<!-- Agent gallery + configuration panel -->
|
||||
<div style="display:flex;gap:16px;padding:10px 0;align-items:flex-start">
|
||||
<!-- Agenttilista (drag & drop) -->
|
||||
<div id="agent-bar" style="display:flex;gap:6px;align-items:flex-end;flex-wrap:wrap">
|
||||
<!-- Renderöidään JS:stä -->
|
||||
</div>
|
||||
<!-- + Lisää agentti -->
|
||||
<div id="add-agent-btn" class="agent-avatar" onclick="addCustomAgent()" title="Lisää oma agentti" style="opacity:0.4">
|
||||
<!-- + Add agent -->
|
||||
<div id="add-agent-btn" class="agent-avatar" onclick="addCustomAgent()" title="Add custom agent" style="opacity:0.4">
|
||||
<div style="width:48px;height:48px;border-radius:50%;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;font-size:24px;color:var(--border)">+</div>
|
||||
<span style="font-size:10px;color:#8b949e;text-align:center;display:block">Lisää</span>
|
||||
<span style="font-size:10px;color:#8b949e;text-align:center;display:block">Add</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agentin konfigurointipaneeli (avautuu klikkaamalla avataria) -->
|
||||
<!-- Agent configuration panel (opens clicking avatar) -->
|
||||
<div id="agent-config" style="display:none;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<img id="config-avatar" src="" style="width:40px;height:40px;border-radius:50%">
|
||||
<div>
|
||||
<input id="config-name" style="background:transparent;border:none;color:var(--text);font-size:16px;font-weight:600;outline:none;width:200px" placeholder="Agentin nimi">
|
||||
<input id="config-name" style="background:transparent;border:none;color:var(--text);font-size:16px;font-weight:600;outline:none;width:200px" placeholder="Agent Name">
|
||||
<div id="config-role" style="font-size:11px;color:#8b949e"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-red" onclick="deleteAgent()" title="Poista agentti">Poista</button>
|
||||
<button class="btn btn-muted" onclick="closeAgentConfig()">Sulje</button>
|
||||
<button class="btn btn-red" onclick="deleteAgent()" title="Delete agent">Delete</button>
|
||||
<button class="btn btn-muted" onclick="closeAgentConfig()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Malli -->
|
||||
<!-- Model -->
|
||||
<div style="margin-bottom:10px">
|
||||
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Kielimalli</label>
|
||||
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Model</label>
|
||||
<select id="config-model" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:13px;width:100%">
|
||||
<option value="qwen-coder">Qwen2.5-Coder:0.5B (selain)</option>
|
||||
<option value="qwen-coder">Qwen2.5-Coder:0.5B (browser)</option>
|
||||
<option value="qwen-coder-3b">Qwen2.5-Coder:3B (Ollama)</option>
|
||||
<option value="qwen2.5-coder:7b">Qwen2.5-Coder:7B (Ollama)</option>
|
||||
<option value="qwen2.5-coder:1.5b">Qwen2.5-Coder:1.5B (Ollama)</option>
|
||||
@@ -39,41 +39,41 @@
|
||||
</div>
|
||||
|
||||
<!-- System prompt -->
|
||||
<div style="margin-bottom:10px" title="Agentin perusohje joka lähetetään kielimallille jokaisessa pyynnössä. Hyvän promptin rakenne: 1. Rooli: 'You are an expert...' 2. Säännöt: RULES/CRITICAL RULES listana 3. Esimerkit: EXAMPLE OUTPUT 4. Kiellot: NEVER-lista Vinkki: käytä englantia — malli ymmärtää sen paremmin ja se kuluttaa vähemmän tokeneita.">
|
||||
<div style="margin-bottom:10px" title="System prompt sent to the LLM on every request. Good prompt structure: 1. Role: 'You are an expert...' 2. Rules: RULES/CRITICAL RULES as list 3. Examples: EXAMPLE OUTPUT 4. Restrictions: NEVER-list ">
|
||||
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px;cursor:help">System prompt 💡</label>
|
||||
<textarea id="config-prompt" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;font-family:'Courier New',monospace;resize:vertical;overflow:hidden;min-height:60px" placeholder="Kuvaa agentin rooli ja käyttäytyminen..."></textarea>
|
||||
<textarea id="config-prompt" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;font-family:'Courier New',monospace;resize:vertical;overflow:hidden;min-height:60px" placeholder="Describe the agent's role and behavior..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Sampling-parametrit -->
|
||||
<!-- Sampling Parameters -->
|
||||
<div style="margin-bottom:10px">
|
||||
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:8px">Sampling-parametrit</label>
|
||||
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:8px">Sampling Parameters</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div title="Kontrolloi 'luovuutta'. Matala arvo (0.2-0.4) tuottaa ennustettavaa, toistettavaa koodia — hyvä testaajille ja reviewereille. Keskiarvo (0.6-0.8) on paras koodin generointiin. Korkea arvo (1.0+) lisää vaihtelua mutta myös virheitä. Suositus: • Manageri: 0.5 (tarkat tiedostolistat) • Koodari: 0.7 (toimiva koodi + vaihtelu) • Testaaja: 0.3 (deterministinen arviointi)">
|
||||
<div title="Controls 'creativity'. Low value (0.2-0.4) produces predictable, repeatable code — good for testers and reviewers. Medium value (0.6-0.8) is best for generating code. High value (1.0+) adds variation but also errors. Recommendation: • Manager: 0.5 (precise file lists) • Coder: 0.7 (working code + variation) • Tester: 0.3 (deterministic evaluation)">
|
||||
<label style="font-size:11px;color:#8b949e;cursor:help">Temperature 💡 <span id="config-temp-val" style="color:var(--accent);float:right">0.7</span></label>
|
||||
<input type="range" id="config-temperature" min="0" max="1.5" step="0.1" value="0.7" style="width:100%;accent-color:var(--accent)">
|
||||
<div style="font-size:10px;color:#30363d">0=tarkka · 0.7=oletus · 1.5=luova</div>
|
||||
<div style="font-size:10px;color:#30363d">0=strict · 0.7=default · 1.5=creative</div>
|
||||
</div>
|
||||
<div title="Vastauksen maksimipituus tokeneina (~1 token ≈ 4 merkkiä). Suositus: • Manageri: 256-512 (lyhyet tiedostolistat) • Koodari: 1024-2048 (täydet tiedostot, CRUD-endpointit) • Testaaja: 256-512 (lyhyet arvioinnit) Jos koodi katkeaa kesken, nosta tätä. Jos malli tuottaa turhaa toistoa, laske.">
|
||||
<div title="Maximum response length in tokens (~1 token ≈ 4 chars). Recommendation: • Manager: 256-512 (short lists) • Coder: 1024-2048 (full files, CRUD endpoints) • Tester: 256-512 (short evaluations) If code cuts off early, increase this.">
|
||||
<label style="font-size:11px;color:#8b949e;cursor:help">Max tokens 💡 <span id="config-maxtok-val" style="color:var(--accent);float:right">1024</span></label>
|
||||
<input type="range" id="config-maxtokens" min="64" max="4096" step="64" value="1024" style="width:100%;accent-color:var(--accent)">
|
||||
<div style="font-size:10px;color:#30363d">Vastauksen maksimipituus</div>
|
||||
<div style="font-size:10px;color:#30363d">Maximum response length</div>
|
||||
</div>
|
||||
<div title="Montako todennäköisintä tokenia huomioidaan valinnassa. Pieni arvo (1-10) tekee vastauksesta deterministisen. Suuri arvo (50-100) sallii harvinaisempia sanoja. Suositus: • Boilerplate-koodi: 20-30 (tutut patternit) • Yleiskoodi: 40 (hyvä oletus) • Luova teksti: 60-80 Yleensä ei tarvitse muuttaa oletuksesta.">
|
||||
<div title="How many most probable tokens are considered. Low value (1-10) makes response deterministic. High value (50-100) allows rarer words. Recommendation: • Boilerplate code: 20-30 (familiar patterns) • General code: 40 (good default) • Creative text: 60-80">
|
||||
<label style="font-size:11px;color:#8b949e;cursor:help">Top-K 💡 <span id="config-topk-val" style="color:var(--accent);float:right">40</span></label>
|
||||
<input type="range" id="config-topk" min="1" max="100" step="1" value="40" style="width:100%;accent-color:var(--accent)">
|
||||
<div style="font-size:10px;color:#30363d">1=greedy · 40=oletus · 100=laaja</div>
|
||||
<div style="font-size:10px;color:#30363d">1=greedy · 40=default · 100=wide</div>
|
||||
</div>
|
||||
<div title="Vähentää jo tuotettujen sanojen todennäköisyyttä. Estää mallia toistamasta samaa lausetta. Liian korkea arvo (>1.5) voi rikkoa koodin koska samat avainsanat (return, if, def) ovat tarpeellisia. Suositus: • Koodi: 1.1-1.2 (lievä, sallii toiston) • Teksti: 1.15-1.3 (vahvempi) • Review: 1.0-1.1 (ei rangaistusta, lyhyet vastaukset)">
|
||||
<div title="Reduces the probability of already generated words. Prevents model from repeating same sentences. Too high value (>1.5) can break code because common keywords (return, if, def) are necessary. Recommendation: • Code: 1.1-1.2 (mild, allows repetition) • Text: 1.15-1.3 (stronger penalty) • Review: 1.0-1.1 (no penalty, short answers)">
|
||||
<label style="font-size:11px;color:#8b949e;cursor:help">Repetition penalty 💡 <span id="config-rep-val" style="color:var(--accent);float:right">1.15</span></label>
|
||||
<input type="range" id="config-repeat" min="1.0" max="2.0" step="0.05" value="1.15" style="width:100%;accent-color:var(--accent)">
|
||||
<div style="font-size:10px;color:#30363d">1.0=ei · 1.15=oletus · 2.0=vahva</div>
|
||||
<div style="font-size:10px;color:#30363d">1.0=none · 1.15=default · 2.0=strong</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline-järjestys -->
|
||||
<!-- Pipeline order -->
|
||||
<div>
|
||||
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Pipeline-järjestys <span style="color:var(--border)">(vedä järjestääksesi)</span></label>
|
||||
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Pipeline Order <span style="color:var(--border)">(drag to sort)</span></label>
|
||||
<div id="config-pipeline" style="display:flex;gap:4px;flex-wrap:wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<!-- Monaco Editor paneeli -->
|
||||
<div id="panel-editor" class="panel">
|
||||
<div style="display:flex;height:calc(100vh - 200px);gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden">
|
||||
<div id="editor-filetree" style="width:200px;min-width:150px;background:var(--bg);border-right:1px solid var(--border);overflow-y:auto;font-family:'Courier New',monospace;font-size:13px">
|
||||
<div style="padding:10px 12px;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">Tiedostot</div>
|
||||
<div style="display:flex;flex:1;min-height:0;gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden">
|
||||
<div id="editor-filetree" style="width:200px;min-width:150px;background:var(--bg);border-right:1px solid var(--border);overflow:auto;resize:horizontal;font-family:'Courier New',monospace;font-size:13px">
|
||||
<div style="padding:10px 12px;color:#8b949e;font-size:11px;display:flex;justify-content:space-between;align-items:center;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">
|
||||
<span>Tiedostot</span>
|
||||
<button class="btn btn-green" style="padding:2px 6px;font-size:10px" onclick="downloadProjectZip()">.ZIP</button>
|
||||
</div>
|
||||
<div id="editor-file-list" style="padding:4px 0">
|
||||
<div style="padding:8px 16px;color:#8b949e;font-size:12px">Generoi projekti:<br><code style="color:var(--accent)">kpn project "..."</code></div>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,49 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline-rajoitteet -->
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-title">Pipeline-rajoitteet</h3>
|
||||
<p class="settings-desc">Projektin generoinnin rajat. Suuremmat arvot = rikkaampi output, hitaampi suoritus.</p>
|
||||
<div class="settings-grid">
|
||||
<div>
|
||||
<label class="settings-label">Client: max sanat <span id="set-plc-words-val" class="settings-val">400</span></label>
|
||||
<input type="range" id="set-plc-words" min="100" max="800" step="50" value="400" class="settings-slider">
|
||||
<div class="settings-hint">Vaatimusmäärittelyn maksimipituus sanoina</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label">Client: max ominaisuudet <span id="set-plc-feats-val" class="settings-val">8</span></label>
|
||||
<input type="range" id="set-plc-feats" min="3" max="15" step="1" value="8" class="settings-slider">
|
||||
<div class="settings-hint">Montako ominaisuutta vaatimuksiin</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label">Manager: max tiedostot <span id="set-plc-mfiles-val" class="settings-val">8</span></label>
|
||||
<input type="range" id="set-plc-mfiles" min="3" max="15" step="1" value="8" class="settings-slider">
|
||||
<div class="settings-hint">Managerin suunnittelemien tiedostojen yläraja</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label">Vapaa tila: max tiedostot <span id="set-plc-ffiles-val" class="settings-val">8</span></label>
|
||||
<input type="range" id="set-plc-ffiles" min="3" max="15" step="1" value="8" class="settings-slider">
|
||||
<div class="settings-hint">Tiedostoraja kun ei mallipohjaa</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label">Review-kierrokset <span id="set-plc-review-val" class="settings-val">3</span></label>
|
||||
<input type="range" id="set-plc-review" min="1" max="5" step="1" value="3" class="settings-slider">
|
||||
<div class="settings-hint">Katselmointi-korjaus-syklien max määrä</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label">Terminaali: max rivit <span id="set-plc-term-val" class="settings-val">300</span></label>
|
||||
<input type="range" id="set-plc-term" min="50" max="1000" step="50" value="300" class="settings-slider">
|
||||
<div class="settings-hint">Terminaalin näyttämien rivien yläraja</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label">CrewAI: prompt-rivit <span id="set-plc-crew-val" class="settings-val">50</span></label>
|
||||
<input type="range" id="set-plc-crew" min="10" max="200" step="10" value="50" class="settings-slider">
|
||||
<div class="settings-hint">tasks.yaml:n promptin max rivimäärä</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset -->
|
||||
<div style="margin-top:24px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<button class="btn btn-red" onclick="resetSettings()" style="padding:6px 16px">Palauta oletukset</button>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div style="color:#e6edf3;font-weight:600;margin-bottom:6px">2. Käynnistä Kipinä-node</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px">
|
||||
<code style="flex:1;background:#010409;padding:8px 12px;border-radius:4px;color:var(--green);font-family:'Courier New',monospace;font-size:13px;user-select:all">curl -sSL "https://kipina.studio/kipina-node?v=$(date +%s)" -o kipina-node && chmod +x kipina-node && ./kipina-node</code>
|
||||
<button onclick="navigator.clipboard.writeText('curl -sSL \"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>
|
||||
<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 style="color:#8b949e;font-size:12px">Lataa kielimallin (~2GB) automaattisesti ensimmäisellä kerralla. Ctrl+C pysäyttää.</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/* Oletusvärit — ylikirjoitetaan teemalla */
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
@@ -8,6 +9,53 @@
|
||||
--red: #f85149;
|
||||
--purple: #a371f7;
|
||||
--border: #30363d;
|
||||
--hero-accent: #ff6b00;
|
||||
--hero-glow: rgba(255, 107, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Gecko — lämmin kulta/oranssi (kipina.tech) */
|
||||
[data-theme="gecko"] {
|
||||
--bg: #0a0500;
|
||||
--panel: #1f1000;
|
||||
--text: #fff5e6;
|
||||
--accent: #ff7b00;
|
||||
--green: #3fb950;
|
||||
--yellow: #ffae00;
|
||||
--red: #f85149;
|
||||
--purple: #ff9d4d;
|
||||
--border: rgba(255, 174, 0, 0.2);
|
||||
--hero-accent: #ff7b00;
|
||||
--hero-glow: rgba(255, 123, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Forge — kyber-sininen/syaani (kipina.tech) */
|
||||
[data-theme="forge"] {
|
||||
--bg: #060b11;
|
||||
--panel: #121e2d;
|
||||
--text: #e0f2fe;
|
||||
--accent: #00e5ff;
|
||||
--green: #3fb950;
|
||||
--yellow: #ff5e3a;
|
||||
--red: #f85149;
|
||||
--purple: #7dd3fc;
|
||||
--border: rgba(0, 229, 255, 0.15);
|
||||
--hero-accent: #00e5ff;
|
||||
--hero-glow: rgba(0, 229, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Serpent — neon-turkoosi/teal (kipina.tech) */
|
||||
[data-theme="serpent"] {
|
||||
--bg: #000808;
|
||||
--panel: #001e1e;
|
||||
--text: #ccffff;
|
||||
--accent: #00ffff;
|
||||
--green: #00ffaa;
|
||||
--yellow: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #66cccc;
|
||||
--border: rgba(0, 255, 255, 0.15);
|
||||
--hero-accent: #00ffff;
|
||||
--hero-glow: rgba(0, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -20,10 +68,24 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container { max-width: 1600px; margin: 0 auto; padding: 20px 40px; }
|
||||
.container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 40px;
|
||||
}
|
||||
|
||||
#app.container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app:not(.active) { display: none; }
|
||||
#landing.hidden { display: none; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-shrink: 0; }
|
||||
.tab {
|
||||
padding: 10px 20px; border-radius: 6px 6px 0 0; cursor: pointer;
|
||||
border: 1px solid var(--border); border-bottom: none;
|
||||
@@ -33,7 +95,7 @@ body {
|
||||
|
||||
/* Panels */
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
.panel.active { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
@@ -52,7 +114,7 @@ body {
|
||||
.terminal {
|
||||
background: #010409; border: 1px solid var(--border); border-top: none;
|
||||
font-family: 'Courier New', monospace; font-size: 16px;
|
||||
min-height: 400px; max-height: 70vh; overflow-y: auto;
|
||||
flex: 1; min-height: 0; max-height: none; overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.terminal-line { padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
|
||||
@@ -81,6 +143,12 @@ body {
|
||||
}
|
||||
.dd-item:hover, .dd-item.active { background: var(--border); color: var(--accent); }
|
||||
|
||||
#editor-file-list .dd-item {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Pipeline progress */
|
||||
.pipeline-bar {
|
||||
display: none; padding: 8px 14px; background: var(--bg);
|
||||
@@ -102,6 +170,7 @@ body {
|
||||
.project-tab {
|
||||
padding: 4px 10px; cursor: pointer; border-radius: 4px 4px 0 0;
|
||||
font-size: 12px; color: #8b949e;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.project-tab.active { background: var(--panel); color: var(--accent); border: 1px solid var(--border); border-bottom: none; }
|
||||
|
||||
@@ -165,6 +234,13 @@ body {
|
||||
.agent-avatar.active img {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 25px rgba(88,166,255,0.8);
|
||||
animation: agentBlink 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes agentBlink {
|
||||
0% { opacity: 0.8; box-shadow: 0 0 15px rgba(88,166,255,0.5); }
|
||||
50% { opacity: 1.0; box-shadow: 0 0 35px rgba(88,166,255,1.0); }
|
||||
100% { opacity: 0.8; box-shadow: 0 0 15px rgba(88,166,255,0.5); }
|
||||
}
|
||||
|
||||
/* Settings */
|
||||
@@ -195,6 +271,218 @@ body {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||||
}
|
||||
|
||||
/* ===== LANDING PAGE ===== */
|
||||
|
||||
#landing {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-mesh {
|
||||
position: fixed; inset: 0; z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 40%, var(--hero-glow) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 50% at 80% 20%, rgba(88,166,255,0.06) 0%, transparent 70%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.landing-nav {
|
||||
padding: 20px 40px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.landing-logo { text-decoration: none; font-size: 18px; font-weight: 700; }
|
||||
.logo-accent { color: var(--hero-accent); }
|
||||
.logo-sub { color: #8b949e; font-weight: 400; }
|
||||
.theme-cycle-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 8px;
|
||||
width: 38px; height: 38px; font-size: 20px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
.theme-cycle-btn:hover {
|
||||
border-color: var(--hero-accent); transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
padding: 60px 40px 40px;
|
||||
}
|
||||
.hero-container {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: grid; grid-template-columns: 1fr 400px; gap: 60px; align-items: center;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: clamp(2rem, 4vw, 3rem); font-weight: 800;
|
||||
line-height: 1.15; color: #e6edf3; margin-bottom: 16px;
|
||||
}
|
||||
.hero-divider {
|
||||
width: 60px; height: 3px; background: var(--hero-accent);
|
||||
border-radius: 2px; margin-bottom: 20px;
|
||||
}
|
||||
.hero-desc {
|
||||
font-size: 1.05rem; color: #8b949e; line-height: 1.7; margin-bottom: 12px;
|
||||
}
|
||||
.hero-notice {
|
||||
font-size: 0.9rem; color: #6e7681; line-height: 1.6;
|
||||
border-left: 2px solid var(--border); padding-left: 12px; margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* Hero input */
|
||||
.hero-input-group {
|
||||
display: flex; gap: 8px; margin-bottom: 20px;
|
||||
}
|
||||
.hero-input {
|
||||
flex: 1; padding: 14px 18px; font-size: 16px;
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
background: var(--panel); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.hero-input:focus {
|
||||
border-color: var(--hero-accent); box-shadow: 0 0 0 3px var(--hero-glow);
|
||||
}
|
||||
.hero-input::placeholder { color: #484f58; }
|
||||
.hero-input.shake {
|
||||
animation: shake 0.4s ease;
|
||||
border-color: #f85149;
|
||||
box-shadow: 0 0 0 3px rgba(248,81,73,0.2);
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20%, 60% { transform: translateX(-6px); }
|
||||
40%, 80% { transform: translateX(6px); }
|
||||
}
|
||||
.hero-btn {
|
||||
padding: 14px 28px; font-size: 16px; font-weight: 600;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--hero-accent); color: #fff; border: none; border-radius: 8px;
|
||||
cursor: pointer; transition: background 0.2s, transform 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero-btn:hover { filter: brightness(0.85); transform: translateY(-1px); }
|
||||
.hero-btn:active { transform: translateY(0); }
|
||||
|
||||
/* Example buttons */
|
||||
.hero-examples { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.hero-examples-label { color: #6e7681; font-size: 14px; margin-right: 4px; }
|
||||
.example-btn {
|
||||
padding: 8px 16px; font-size: 13px; font-family: 'Inter', sans-serif;
|
||||
background: transparent; color: var(--accent);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.example-btn:hover {
|
||||
border-color: var(--accent); background: rgba(88,166,255,0.08);
|
||||
}
|
||||
|
||||
/* Hero orb */
|
||||
.hero-orb-wrapper {
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
}
|
||||
.hero-orb {
|
||||
width: 340px; height: 340px; border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, var(--hero-glow) 0%, transparent 70%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: orb-float 6s ease-in-out infinite;
|
||||
}
|
||||
.hero-orb-img {
|
||||
width: 100%; height: 100%; object-fit: contain;
|
||||
filter: drop-shadow(0 0 40px var(--hero-glow));
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@keyframes orb-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
}
|
||||
|
||||
/* How section */
|
||||
.how-section {
|
||||
padding: 60px 40px;
|
||||
background: rgba(22,27,34,0.6);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.how-container { max-width: 900px; margin: 0 auto; }
|
||||
.how-title {
|
||||
text-align: center; font-size: 1.5rem; font-weight: 700;
|
||||
color: #e6edf3; margin-bottom: 40px;
|
||||
}
|
||||
.how-steps {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px;
|
||||
}
|
||||
.how-step {
|
||||
text-align: center; padding: 24px;
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 12px; transition: border-color 0.3s;
|
||||
}
|
||||
.how-step:hover { border-color: var(--hero-accent); }
|
||||
.how-step-num {
|
||||
width: 40px; height: 40px; line-height: 40px;
|
||||
border-radius: 50%; background: var(--hero-glow);
|
||||
color: var(--hero-accent); font-weight: 700; font-size: 18px;
|
||||
margin: 0 auto 14px;
|
||||
}
|
||||
.how-step h3 { color: #e6edf3; font-size: 1rem; margin-bottom: 8px; }
|
||||
.how-step p { color: #8b949e; font-size: 0.9rem; line-height: 1.5; }
|
||||
|
||||
/* Landing footer */
|
||||
.landing-footer {
|
||||
text-align: center; padding: 32px 40px;
|
||||
color: #484f58; font-size: 13px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.landing-footer a { color: #8b949e; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 860px) {
|
||||
.hero-container { grid-template-columns: 1fr; gap: 32px; }
|
||||
.hero-orb-wrapper { order: -1; }
|
||||
.hero-orb { width: 220px; height: 220px; }
|
||||
.how-steps { grid-template-columns: 1fr; }
|
||||
.hero-input-group { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ===== OPPIMISPOLKU ===== */
|
||||
.learn-step {
|
||||
margin: 12px 0; border: 1px solid var(--border);
|
||||
border-radius: 8px; background: var(--panel); overflow: hidden;
|
||||
}
|
||||
.learn-step-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 16px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.learn-step-header:hover { background: rgba(88,166,255,0.04); }
|
||||
.learn-step-num {
|
||||
width: 28px; height: 28px; line-height: 28px; text-align: center;
|
||||
border-radius: 50%; background: var(--hero-glow);
|
||||
color: var(--hero-accent); font-weight: 700; font-size: 13px; flex-shrink: 0;
|
||||
}
|
||||
.learn-step-agent {
|
||||
font-weight: 600; color: #e6edf3; font-size: 14px;
|
||||
}
|
||||
.learn-step-label {
|
||||
color: #8b949e; font-size: 13px; margin-left: auto;
|
||||
}
|
||||
.learn-step-body {
|
||||
display: none; padding: 0 16px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.learn-step-body.open { display: block; }
|
||||
.learn-section-title {
|
||||
color: var(--accent); font-size: 12px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.learn-code {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 12px; line-height: 1.6;
|
||||
background: #010409; border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 12px; overflow-x: auto;
|
||||
max-height: 300px; overflow-y: auto; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes blink { 0%,100% { opacity:1 } 50% { opacity:0 } }
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s
|
||||
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-12T03:21:35.058711Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Tietokanta alustettu
|
||||
[2m2026-04-12T03:21:35.061635Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Kipinä Agent Hub v0.3.1 käynnistyy osoitteessa http://localhost:3000
|
||||
[2m2026-04-12T03:21:49.577628Z[0m [32m INFO[0m [2mhub[0m[2m:[0m Solmu 1 yhdistyi osoitteesta 127.0.0.1
|
||||
[2m2026-04-12T03:21:49.600021Z[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-12T03:21:49.600099Z[0m [32m INFO[0m [2mhub[0m[2m:[0m GPU 0: Apple M2 Max | VRAM: 0/24576 MB | 0°C | 0%
|
||||
[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]
|
||||
name = "hub"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -11,7 +11,6 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1.7.0", features = ["v4", "serde"] }
|
||||
futures = "0.3"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
chrono = "0.4"
|
||||
|
||||
@@ -42,10 +42,12 @@ struct AppState {
|
||||
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_active_task: Mutex<HashMap<u64, String>>, // node_id → task_id (mikä tehtävä on kesken)
|
||||
pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi)
|
||||
pending_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ä)
|
||||
node_models: tokio::sync::RwLock<HashMap<u64, serde_json::Value>>, // node_id → ollama tags JSON
|
||||
node_max_param_b: tokio::sync::RwLock<HashMap<u64, u32>>, // node_id → suurimman mallin parametrit (B)
|
||||
db: db::NodeDb,
|
||||
}
|
||||
|
||||
@@ -196,7 +198,7 @@ async function load() {
|
||||
].map(s => `<div class="stat-card"><div class="val">${s.v}</div><div class="label">${s.l}</div></div>`).join('');
|
||||
|
||||
// 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) => {
|
||||
const aOnline = !a.disconnected_at;
|
||||
const bOnline = !b.disconnected_at;
|
||||
@@ -329,10 +331,12 @@ async fn main() {
|
||||
node_types: Mutex::new(HashMap::new()),
|
||||
node_paused: Mutex::new(std::collections::HashSet::new()),
|
||||
node_busy: Mutex::new(std::collections::HashSet::new()),
|
||||
node_active_task: Mutex::new(HashMap::new()),
|
||||
pending_task_ids: Mutex::new(std::collections::HashSet::new()),
|
||||
pending_responses: Mutex::new(HashMap::new()),
|
||||
api_rate_limits: Mutex::new(HashMap::new()),
|
||||
node_models: tokio::sync::RwLock::new(HashMap::new()),
|
||||
node_max_param_b: tokio::sync::RwLock::new(HashMap::new()),
|
||||
db: db::NodeDb::new(&std::env::var("DATABASE_PATH").unwrap_or_else(|_| "nodes.db".to_string())),
|
||||
});
|
||||
|
||||
@@ -419,9 +423,7 @@ async fn main() {
|
||||
// Vapaa node -> lähetetään oikea tehtävä
|
||||
let msg = match task.as_str() {
|
||||
"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" })),
|
||||
"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ä
|
||||
};
|
||||
|
||||
@@ -846,10 +848,34 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
node_id, ip, hostname, os, cores, ram, allocated
|
||||
);
|
||||
|
||||
// Tallennetaan välitetyt mallit muistiin
|
||||
// Tallennetaan välitetyt mallit muistiin + parsitaan suurin malli
|
||||
if let Some(models) = json.get("models") {
|
||||
let mut nm = state.node_models.write().await;
|
||||
nm.insert(node_id, models.clone());
|
||||
|
||||
// Parsitaan suurin mallikoko (B) nimestä: "qwen3:32b" → 32, "qwen2.5-coder:7b" → 7
|
||||
let max_b = models.get("models").and_then(|v| v.as_array()).map(|arr| {
|
||||
arr.iter().filter_map(|m| {
|
||||
let name = m.get("name")?.as_str()?;
|
||||
// Etsitään :N tai :Nb tai -Nb muoto
|
||||
let lower = name.to_lowercase();
|
||||
for part in lower.split(&[':', '-'][..]) {
|
||||
if let Some(num_str) = part.strip_suffix('b') {
|
||||
if let Ok(n) = num_str.parse::<f32>() { return Some(n as u32); }
|
||||
} else if let Ok(n) = part.parse::<f32>() {
|
||||
if n >= 0.5 && n <= 500.0 { return Some(n as u32); }
|
||||
}
|
||||
}
|
||||
// Fallback: koko tiedostosta (size / ~0.5GB per B param Q4)
|
||||
let size = m.get("size")?.as_u64()?;
|
||||
Some((size / 500_000_000) as u32) // karkea arvio
|
||||
}).max().unwrap_or(0)
|
||||
}).unwrap_or(0);
|
||||
|
||||
if max_b > 0 {
|
||||
state.node_max_param_b.write().await.insert(node_id, max_b);
|
||||
tracing::info!("Solmu {} — suurin malli: ~{}B parametria", node_id, max_b);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(gpus) = json.get("gpus").and_then(|v| v.as_array()) {
|
||||
@@ -910,6 +936,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
broadcast_stats(&state).await;
|
||||
} else if msg_type == "pair_done" {
|
||||
state.node_busy.lock().unwrap().remove(&node_id);
|
||||
state.node_active_task.lock().unwrap().remove(&node_id);
|
||||
{
|
||||
let mut json = json; // Siirretään omistajuus muokkausta varten
|
||||
if let Some(obj) = json.as_object_mut() {
|
||||
@@ -996,6 +1023,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
} else if msg_type == "llm_done" {
|
||||
// Vapautetaan solmu ja tarkistetaan task_id:n aitous
|
||||
state.node_busy.lock().unwrap().remove(&node_id);
|
||||
state.node_active_task.lock().unwrap().remove(&node_id);
|
||||
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let valid_task = if let Some(ref tid) = task_id {
|
||||
state.pending_task_ids.lock().unwrap().remove(tid.as_str())
|
||||
@@ -1013,15 +1041,15 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
if let Some(obj) = json.as_object_mut() {
|
||||
let model = obj.get("model").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let prompt = obj.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let response = obj.get("response").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let _response = obj.get("response").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let tok_gen = obj.get("tokens_generated").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let duration = obj.get("duration_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let tok_s = obj.get("tokens_per_sec").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
println!();
|
||||
println!("\x1b[35m━━━ Solmu {} ━━━ {} ━━━\x1b[0m", node_id, model);
|
||||
println!(" Prompt: \x1b[33m\"{}\"\x1b[0m", prompt);
|
||||
println!(" Vastaus: \x1b[32m{}\x1b[0m", response);
|
||||
let prompt_preview: String = prompt.chars().take(80).collect();
|
||||
println!(" Prompt: \x1b[33m\"{}...\"\x1b[0m", prompt_preview);
|
||||
println!(" {} tokenia | {:.0}ms | \x1b[36m{:.1} tok/s\x1b[0m", tok_gen, duration, tok_s);
|
||||
|
||||
state.db.increment_tasks(node_id);
|
||||
@@ -1065,6 +1093,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
}
|
||||
} else if msg_type == "llm_error" {
|
||||
state.node_busy.lock().unwrap().remove(&node_id);
|
||||
state.node_active_task.lock().unwrap().remove(&node_id);
|
||||
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
if let Some(ref tid) = task_id {
|
||||
state.pending_task_ids.lock().unwrap().remove(tid.as_str());
|
||||
@@ -1111,6 +1140,22 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
|
||||
// Yhteys katkesi — merkitään session päättyneeksi ja siivotaan atomisesti
|
||||
state.db.close_session(node_id);
|
||||
|
||||
// Jos solmulla oli kesken tehtävä, ilmoitetaan odottavalle API-kutsulle
|
||||
let lost_task_id = state.node_active_task.lock().unwrap().remove(&node_id);
|
||||
if let Some(tid) = lost_task_id {
|
||||
tracing::warn!("Solmu {} katosi kesken tehtävän {} — palautetaan virhe API:lle", node_id, tid);
|
||||
state.pending_task_ids.lock().unwrap().remove(&tid);
|
||||
if let Some(resp_tx) = state.pending_responses.lock().unwrap().remove(&tid) {
|
||||
let err = serde_json::json!({
|
||||
"type": "llm_error",
|
||||
"error": format!("Solmu #{} katosi kesken laskennan (task {})", node_id, tid),
|
||||
"task_id": tid
|
||||
});
|
||||
let _ = resp_tx.send(err);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Lukitaan kaikki kerralla, jotta solmu ei ole osittain siivottu
|
||||
let mut tasks = state.node_tasks.lock().unwrap();
|
||||
@@ -1130,6 +1175,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
state.node_types.lock().unwrap().remove(&node_id);
|
||||
state.node_paused.lock().unwrap().remove(&node_id);
|
||||
state.node_models.write().await.remove(&node_id);
|
||||
state.node_max_param_b.write().await.remove(&node_id);
|
||||
tracing::info!("Solmu {} ({}) poistui verkosta.", node_id, ip);
|
||||
broadcast_stats(&state).await;
|
||||
sender_task.abort();
|
||||
@@ -1151,6 +1197,8 @@ struct ChatCompletionRequest {
|
||||
repeat_penalty: Option<f64>,
|
||||
#[serde(default)]
|
||||
stop: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
capability: Option<String>, // "heavy" → priorisoi isoin malli, "light" → mikä tahansa
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -1260,15 +1308,26 @@ async fn api_chat_completions(
|
||||
}
|
||||
}
|
||||
|
||||
// Etsitään vapaa solmu — priorisoidaan natiivisolmut (GPU) selaimen edelle
|
||||
// Etsitään vapaa solmu — älykäs reititys kyvykkyyden mukaan
|
||||
let want_heavy = payload.capability.as_deref() == Some("heavy");
|
||||
// Haetaan param_b-snapshot ennen Mutex-lukituksia (async RwLock ei saa olla Mutex-scopen sisällä)
|
||||
let param_b_snapshot: HashMap<u64, u32> = state.node_max_param_b.read().await.clone();
|
||||
let (target_node, _total_matching) = {
|
||||
let 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 paused = state.node_paused.lock().unwrap();
|
||||
// Debug: logita kaikki solmut ja niiden tilat
|
||||
let all_nodes: Vec<String> = tasks.iter().map(|(id, task)| {
|
||||
let ty = node_types.get(id).map(|s| s.as_str()).unwrap_or("?");
|
||||
let b = if busy.contains(id) { " BUSY" } else { "" };
|
||||
let p = if paused.contains(id) { " PAUSED" } else { "" };
|
||||
format!("#{}({}:{}{}{}", id, ty, task, b, p)
|
||||
}).collect();
|
||||
tracing::info!("Reititys '{}'{} — solmut: [{}]", payload.model, if want_heavy { " (heavy)" } else { "" }, all_nodes.join(", "));
|
||||
let matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
|
||||
if paused.contains(k) { return false; } // Ei sallita tauotettuja
|
||||
// Eksakti match tai qwen-perheen yhteensopivuus (selain: qwen-coder-05b, natiivi: qwen2.5-coder:7b)
|
||||
if paused.contains(k) { return false; } // Ei tauotettuja
|
||||
if busy.contains(k) { return false; } // Ei varattuja
|
||||
let req_model = payload.model.to_lowercase();
|
||||
let node_task = task.to_lowercase();
|
||||
if req_model.starts_with("qwen") {
|
||||
@@ -1279,11 +1338,32 @@ async fn api_chat_completions(
|
||||
**task == payload.model
|
||||
}
|
||||
}).map(|(k, _)| *k).collect();
|
||||
// Etsitään mikä tahansa matchaava solmu (natiivi priorisoidaan)
|
||||
let native = matching.iter().find(|id| {
|
||||
node_types.get(id).map(|t| t == "native").unwrap_or(false)
|
||||
}).copied();
|
||||
let any = native.or_else(|| matching.first().copied());
|
||||
|
||||
let any = if want_heavy {
|
||||
// Heavy: priorisoi solmu jolla on suurin malli (B-parametrit)
|
||||
let mut ranked: Vec<(u64, u32)> = matching.iter().map(|id| {
|
||||
(*id, param_b_snapshot.get(id).copied().unwrap_or(0))
|
||||
}).collect();
|
||||
ranked.sort_by(|a, b| b.1.cmp(&a.1)); // suurin ensin
|
||||
if let Some((best_id, best_b)) = ranked.first() {
|
||||
tracing::info!("Heavy-reititys: solmu {} valittu ({}B parametria)", best_id, best_b);
|
||||
Some(*best_id)
|
||||
} else {
|
||||
// Kaikki heavy-solmut busy — fallback mihin tahansa vapaaseen
|
||||
let all_matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
|
||||
if paused.contains(k) || busy.contains(k) { return false; }
|
||||
let req_model = payload.model.to_lowercase();
|
||||
task.to_lowercase().starts_with(&req_model.split('-').next().unwrap_or(""))
|
||||
}).map(|(k, _)| *k).collect();
|
||||
all_matching.first().copied()
|
||||
}
|
||||
} else {
|
||||
// Oletus: vapaa natiivi ensin, sitten mikä tahansa vapaa
|
||||
let native = matching.iter().find(|id| {
|
||||
node_types.get(id).map(|t| t == "native").unwrap_or(false)
|
||||
}).copied();
|
||||
native.or_else(|| matching.first().copied())
|
||||
};
|
||||
(any, matching.len())
|
||||
};
|
||||
|
||||
@@ -1310,6 +1390,7 @@ async fn api_chat_completions(
|
||||
|
||||
// Merkitään solmu varatuksi ja task_id jaetuksi
|
||||
state.node_busy.lock().unwrap().insert(target_node_id);
|
||||
state.node_active_task.lock().unwrap().insert(target_node_id, payload.task_id.clone());
|
||||
state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone());
|
||||
|
||||
let mut msg = serde_json::json!({
|
||||
@@ -1342,7 +1423,7 @@ async fn api_chat_completions(
|
||||
}
|
||||
}
|
||||
|
||||
let timeout = tokio::time::timeout(std::time::Duration::from_secs(600), resp_rx).await;
|
||||
let timeout = tokio::time::timeout(std::time::Duration::from_secs(120), resp_rx).await;
|
||||
|
||||
match timeout {
|
||||
Ok(Ok(v)) => {
|
||||
@@ -1358,12 +1439,17 @@ async fn api_chat_completions(
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
// Oneshot-kanava sulkeutui (solmu katosi)
|
||||
// Oneshot-kanava sulkeutui (solmu katosi kesken laskennan)
|
||||
state.pending_responses.lock().unwrap().remove(&payload.task_id);
|
||||
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response()
|
||||
state.node_busy.lock().unwrap().remove(&target_node_id);
|
||||
state.node_active_task.lock().unwrap().remove(&target_node_id);
|
||||
(axum::http::StatusCode::SERVICE_UNAVAILABLE, "Solmu katosi kesken laskennan — yritä uudelleen").into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout — solmu ei vastannut ajoissa
|
||||
state.pending_responses.lock().unwrap().remove(&payload.task_id);
|
||||
state.node_busy.lock().unwrap().remove(&target_node_id);
|
||||
state.node_active_task.lock().unwrap().remove(&target_node_id);
|
||||
(axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Kipinä Agentic Studio — asennusskripti (Debian/Ubuntu)
|
||||
set -e
|
||||
|
||||
echo "=== Kipinä Agentic Studio — Asennus ==="
|
||||
echo ""
|
||||
|
||||
# Tarkistetaan käyttöjärjestelmä
|
||||
if [ ! -f /etc/debian_version ]; then
|
||||
echo "⚠ Tämä skripti on suunniteltu Debian/Ubuntu-järjestelmille."
|
||||
echo " Muilla jakeluilla voit asentaa riippuvuudet manuaalisesti."
|
||||
read -p " Jatketaanko? (k/e) " -n 1 -r; echo
|
||||
[[ $REPLY =~ ^[Kk]$ ]] || exit 1
|
||||
fi
|
||||
|
||||
echo "[1/6] Päivitetään pakettilistaus..."
|
||||
sudo apt-get update -qq
|
||||
|
||||
echo "[2/6] Asennetaan peruspaketteja..."
|
||||
sudo apt-get install -y -qq curl git build-essential pkg-config libssl-dev
|
||||
|
||||
# Rust
|
||||
if command -v rustc &>/dev/null; then
|
||||
echo "[3/6] Rust löytyi: $(rustc --version)"
|
||||
else
|
||||
echo "[3/6] Asennetaan Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
# Node.js (Astro-frontend vaatii)
|
||||
if command -v node &>/dev/null; then
|
||||
echo "[4/6] Node.js löytyi: $(node --version)"
|
||||
else
|
||||
echo "[4/6] Asennetaan Node.js 22..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y -qq nodejs
|
||||
fi
|
||||
|
||||
# Ollama
|
||||
if command -v ollama &>/dev/null; then
|
||||
echo "[5/6] Ollama löytyi"
|
||||
else
|
||||
echo "[5/6] Asennetaan Ollama..."
|
||||
curl -fsSL https://ollama.ai/install.sh | sh
|
||||
fi
|
||||
|
||||
# Malli
|
||||
echo "[6/6] Ladataan kielimalli (qwen2.5-coder:3b)..."
|
||||
ollama pull qwen2.5-coder:3b
|
||||
|
||||
echo ""
|
||||
echo "=== Asennus valmis! ==="
|
||||
echo ""
|
||||
echo "Käynnistä:"
|
||||
echo " cd $(pwd)"
|
||||
echo " ./network-poc/local.sh"
|
||||
echo ""
|
||||
echo "Avaa selaimessa: http://localhost:3000"
|
||||
135
network-poc/kipina-node
Normal file
135
network-poc/kipina-node
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# Kipinä Node — lataa oikea binääri ja käynnistä
|
||||
set -e
|
||||
|
||||
BASE_URL="https://kipina.studio/download"
|
||||
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
|
||||
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
|
||||
|
||||
# Tunnista OS ja arkkitehtuuri
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS-$ARCH" in
|
||||
darwin-arm64) BINARY="kipina-node-macos-arm64" ;;
|
||||
darwin-x86_64) BINARY="kipina-node-macos-arm64" ;; # Rosetta
|
||||
linux-x86_64) BINARY="kipina-node-linux-x86_64" ;;
|
||||
linux-aarch64) BINARY="kipina-node-linux-arm64" ;;
|
||||
*) echo "Ei tuettu: $OS-$ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo " ╔══════════════════════════════════════╗"
|
||||
echo " ║ Kipinä Agentic Node ║"
|
||||
echo " ╚══════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " OS: $OS ($ARCH)"
|
||||
echo ""
|
||||
|
||||
# Etsi Ollama-instanssit
|
||||
CANDIDATES=(
|
||||
"http://localhost:11434"
|
||||
"http://127.0.0.1:11434"
|
||||
"http://ollama:11434"
|
||||
"http://host.docker.internal:11434"
|
||||
)
|
||||
|
||||
# Lisää OLLAMA_URL listaan jos asetettu ja ei jo mukana
|
||||
if [ -n "$OLLAMA_URL" ]; then
|
||||
ALREADY=false
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
[ "$c" = "$OLLAMA_URL" ] && ALREADY=true
|
||||
done
|
||||
$ALREADY || CANDIDATES=("$OLLAMA_URL" "${CANDIDATES[@]}")
|
||||
fi
|
||||
|
||||
echo " Etsitään Ollama-instansseja..."
|
||||
FOUND=()
|
||||
for url in "${CANDIDATES[@]}"; do
|
||||
if curl -s --connect-timeout 1 "$url/api/tags" &>/dev/null; then
|
||||
FOUND+=("$url")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FOUND[@]} -eq 0 ]; then
|
||||
# Ei löytynyt — yritä käynnistää lokaali
|
||||
if command -v ollama &>/dev/null; then
|
||||
echo " Käynnistetään Ollama..."
|
||||
ollama serve &>/dev/null &
|
||||
sleep 3
|
||||
if curl -s --connect-timeout 1 "http://localhost:11434/api/tags" &>/dev/null; then
|
||||
OLLAMA_URL="http://localhost:11434"
|
||||
echo " ✓ Ollama käynnistetty ($OLLAMA_URL)"
|
||||
else
|
||||
echo " ✗ Ollaman käynnistys epäonnistui."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo " ✗ Ollamaa ei löytynyt."
|
||||
echo " Kontti/remote: OLLAMA_URL=http://HOST:11434 ./kipina-node"
|
||||
echo " Asenna: curl -fsSL https://ollama.ai/install.sh | sh"
|
||||
exit 1
|
||||
fi
|
||||
elif [ ${#FOUND[@]} -eq 1 ]; then
|
||||
OLLAMA_URL="${FOUND[0]}"
|
||||
echo " ✓ Ollama löytyi: $OLLAMA_URL"
|
||||
else
|
||||
echo ""
|
||||
echo " Löytyi ${#FOUND[@]} Ollama-instanssia:"
|
||||
echo ""
|
||||
for i in "${!FOUND[@]}"; do
|
||||
echo " $((i+1))) ${FOUND[$i]}"
|
||||
done
|
||||
echo ""
|
||||
read -p " Valitse [1-${#FOUND[@]}]: " -r CHOICE
|
||||
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le ${#FOUND[@]} ]; then
|
||||
OLLAMA_URL="${FOUND[$((CHOICE-1))]}"
|
||||
else
|
||||
OLLAMA_URL="${FOUND[0]}"
|
||||
echo " Käytetään oletusta: $OLLAMA_URL"
|
||||
fi
|
||||
echo " ✓ Valittu: $OLLAMA_URL"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Hub: $HUB_URL"
|
||||
echo " Ollama: $OLLAMA_URL"
|
||||
if [ -n "$KIPINA_MODEL" ]; then
|
||||
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
|
||||
fi
|
||||
|
||||
# Binäärin automaattinen päivitys — vertaa build-hashia palvelimeen
|
||||
BIN_PATH="./kipina-node-bin"
|
||||
HASH_PATH="./kipina-node-bin.hash"
|
||||
|
||||
REMOTE_HASH=$(curl -sSL "$BASE_URL/.build-hash?v=$(date +%s)" 2>/dev/null | tr -d '[:space:]')
|
||||
LOCAL_HASH=""
|
||||
[ -f "$HASH_PATH" ] && LOCAL_HASH=$(cat "$HASH_PATH" | tr -d '[:space:]')
|
||||
|
||||
if [ -f "$BIN_PATH" ] && [ -n "$REMOTE_HASH" ] && [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
|
||||
echo " ✓ Binääri ajan tasalla (versio: $LOCAL_HASH)"
|
||||
else
|
||||
if [ -f "$BIN_PATH" ]; then
|
||||
echo " ↻ Uusi versio saatavilla ($LOCAL_HASH → $REMOTE_HASH)"
|
||||
else
|
||||
echo " Ladataan $BINARY..."
|
||||
fi
|
||||
rm -f "$BIN_PATH"
|
||||
curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
|
||||
chmod +x "$BIN_PATH"
|
||||
echo "$REMOTE_HASH" > "$HASH_PATH"
|
||||
echo " ✓ Päivitetty versioon $REMOTE_HASH"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " ✓ Siirrytään Kipinä Noden hallintaan..."
|
||||
echo " Ctrl+C pysäyttää"
|
||||
echo ""
|
||||
|
||||
if [ -n "$KIPINA_MODEL" ]; then
|
||||
export OLLAMA_MODEL="$KIPINA_MODEL"
|
||||
fi
|
||||
export HUB_URL="$HUB_URL"
|
||||
export OLLAMA_URL="$OLLAMA_URL"
|
||||
exec "$BIN_PATH"
|
||||
@@ -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
|
||||
@@ -20,7 +20,7 @@ reqwest = { version = "0.12", features = ["json"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dialoguer = "0.12.0"
|
||||
console = "0.16.3"
|
||||
ratatui = "0.29.0"
|
||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||
tracing-appender = "0.2.4"
|
||||
chrono = "0.4"
|
||||
|
||||
@@ -69,6 +69,10 @@ impl LlmEngine {
|
||||
self.model.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn ollama_url(&self) -> &str {
|
||||
&self.ollama_url
|
||||
}
|
||||
|
||||
pub fn set_model(&self, new_model: String) {
|
||||
*self.model.borrow_mut() = new_model;
|
||||
}
|
||||
@@ -91,6 +95,32 @@ impl LlmEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hakee käynnissä olevan mallin VRAM-tilan (ollama ps)
|
||||
pub async fn fetch_ps(&self) -> Result<Option<ModelVramStatus>, String> {
|
||||
let resp = self.client.get(format!("{}/api/ps", self.ollama_url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Ollama ps: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Ollama ps HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("Ollama ps json: {}", e))?;
|
||||
|
||||
let models = body["models"].as_array();
|
||||
if let Some(arr) = models {
|
||||
if let Some(m) = arr.first() {
|
||||
let name = m["name"].as_str().unwrap_or("?").to_string();
|
||||
let size = m["size"].as_u64().unwrap_or(0);
|
||||
let size_vram = m["size_vram"].as_u64().unwrap_or(0);
|
||||
return Ok(Some(ModelVramStatus { name, size, size_vram }));
|
||||
}
|
||||
}
|
||||
Ok(None) // ei ladattua mallia
|
||||
}
|
||||
|
||||
/// Hakee kaikki Ollamaan asennetut mallit
|
||||
pub async fn fetch_models(&self) -> Result<serde_json::Value, String> {
|
||||
let resp = self.client.get(format!("{}/api/tags", self.ollama_url))
|
||||
@@ -109,16 +139,24 @@ impl LlmEngine {
|
||||
let model = self.model.borrow().clone();
|
||||
|
||||
let default_stop: Vec<String> = vec![
|
||||
"<|im_end|>".into(), "\n###".into(), "\nExplanation".into(),
|
||||
"\nNote:".into(), "\nPlease note".into(), "\nThis is".into(),
|
||||
"\n```\n\n".into(), "\n// Example".into(), "\n# Example".into(),
|
||||
"<|im_end|>".into(),
|
||||
];
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
// 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,
|
||||
"prompt": prompt,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
"options": {
|
||||
"num_ctx": 16384,
|
||||
"num_predict": opts.max_tokens,
|
||||
"temperature": opts.temperature.unwrap_or(0.7),
|
||||
"top_k": opts.top_k.unwrap_or(40),
|
||||
@@ -126,16 +164,13 @@ impl LlmEngine {
|
||||
"stop": opts.stop.as_ref().unwrap_or(&default_stop),
|
||||
}
|
||||
});
|
||||
if let Some(ref sp) = opts.system_prompt {
|
||||
body.as_object_mut().unwrap().insert("system".to_string(), serde_json::json!(sp));
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let resp = self.client.post(format!("{}/api/generate", self.ollama_url))
|
||||
let resp = self.client.post(format!("{}/api/chat", self.ollama_url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Ollama generate: {}", e))?;
|
||||
.map_err(|e| format!("Ollama chat: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Ollama HTTP {}", resp.status()));
|
||||
@@ -144,7 +179,7 @@ impl LlmEngine {
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.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 eval_count = body["eval_count"].as_u64().unwrap_or(0) as usize;
|
||||
let eval_duration_ns = body["eval_duration"].as_u64().unwrap_or(1);
|
||||
@@ -163,40 +198,15 @@ impl LlmEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Siivoa markdown-koodiblokki-merkit ja selitystekstit
|
||||
/// Siivoa markdown-koodiblokki-merkit vastauksesta
|
||||
fn strip_code_fences(text: &str) -> String {
|
||||
// Poistetaan kaikki ```-rivit ja kielitunnisteet (```python, ```rust jne.)
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let filtered: Vec<&str> = lines.into_iter().filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
// Poista rivit jotka ovat pelkkiä ``` tai ```kielitunniste
|
||||
if trimmed.starts_with("```") {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
trimmed != "```" && !(trimmed.starts_with("```") && !trimmed[3..].contains('`'))
|
||||
}).collect();
|
||||
let mut result = 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()
|
||||
filtered.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
pub struct GenerateResult {
|
||||
@@ -205,3 +215,32 @@ pub struct GenerateResult {
|
||||
pub duration_ms: f64,
|
||||
pub tokens_per_sec: f64,
|
||||
}
|
||||
|
||||
pub struct ModelVramStatus {
|
||||
pub name: String,
|
||||
pub size: u64, // kokonaiskoko (tavuina)
|
||||
pub size_vram: u64, // VRAM:ssa oleva osuus (tavuina)
|
||||
}
|
||||
|
||||
impl ModelVramStatus {
|
||||
pub fn fully_in_vram(&self) -> bool {
|
||||
self.size > 0 && self.size_vram >= self.size
|
||||
}
|
||||
|
||||
pub fn vram_percent(&self) -> f64 {
|
||||
if self.size == 0 { return 0.0; }
|
||||
(self.size_vram as f64 / self.size as f64) * 100.0
|
||||
}
|
||||
|
||||
pub fn display(&self) -> String {
|
||||
let size_gb = self.size as f64 / 1_073_741_824.0;
|
||||
let vram_gb = self.size_vram as f64 / 1_073_741_824.0;
|
||||
if self.fully_in_vram() {
|
||||
format!("✓ {} ({:.1} GB) — 100% GPU", self.name, size_gb)
|
||||
} else if self.size_vram == 0 {
|
||||
format!("✗ {} ({:.1} GB) — 100% CPU", self.name, size_gb)
|
||||
} else {
|
||||
format!("◐ {} ({:.1}/{:.1} GB VRAM, {:.0}% GPU)", self.name, vram_gb, size_gb, self.vram_percent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use std::io::IsTerminal;
|
||||
use sysinfo::System;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
@@ -362,13 +363,59 @@ async fn main() {
|
||||
st.push_log("System", format!("Malli valmis: {}", active_model), None);
|
||||
}
|
||||
|
||||
// Käynnistetään graafinen TUI vasta kun TUI:n Prompt (LlmEngine::load) on ohitettu!
|
||||
let ui_state = tui_state.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = tui_dashboard::run_dashboard(ui_state, cmd_tx).await {
|
||||
tracing::error!("Pääluupin TUI kaatui: {}", e);
|
||||
// Lämmittelykutsu: ladataan malli VRAM:iin ja haetaan VRAM-tila
|
||||
if let Some(ref engine) = llm {
|
||||
{
|
||||
let mut st = tui_state.write().await;
|
||||
st.vram_status = "Ladataan VRAM:iin...".to_string();
|
||||
st.push_log("System", "Ladataan mallia VRAM:iin...".to_string(), None);
|
||||
}
|
||||
});
|
||||
// Lyhyt generate-kutsu pakottaa Ollaman lataamaan mallin GPU:lle
|
||||
let _ = engine.generate("hi", &inference::GenerateOptions {
|
||||
max_tokens: 1, system_prompt: None, temperature: Some(0.0),
|
||||
top_k: Some(1), repeat_penalty: None, stop: None,
|
||||
}).await;
|
||||
if let Ok(Some(ps)) = engine.fetch_ps().await {
|
||||
let mut st = tui_state.write().await;
|
||||
st.vram_status = ps.display();
|
||||
st.push_log("System", format!("VRAM: {}", ps.display()), None);
|
||||
}
|
||||
let vram_engine_url = engine.ollama_url().to_string();
|
||||
let vram_state = tui_state.clone();
|
||||
tokio::spawn(async move {
|
||||
let client = reqwest::Client::new();
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
if let Ok(resp) = client.get(format!("{}/api/ps", vram_engine_url)).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(arr) = body["models"].as_array() {
|
||||
if let Some(m) = arr.first() {
|
||||
let name = m["name"].as_str().unwrap_or("?").to_string();
|
||||
let size = m["size"].as_u64().unwrap_or(0);
|
||||
let size_vram = m["size_vram"].as_u64().unwrap_or(0);
|
||||
let status = inference::ModelVramStatus { name, size, size_vram };
|
||||
vram_state.write().await.vram_status = status.display();
|
||||
} else {
|
||||
vram_state.write().await.vram_status = "Ei ladattua mallia".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Käynnistetään graafinen TUI vain jos stdin on terminaali (ei taustaprosessina)
|
||||
let ui_state = tui_state.clone();
|
||||
if std::io::stdin().is_terminal() {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = tui_dashboard::run_dashboard(ui_state, cmd_tx).await {
|
||||
tracing::error!("Pääluupin TUI kaatui: {}", e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tracing::info!("Ei terminaalia — TUI ohitettu, lokitetaan stdoutiin");
|
||||
};
|
||||
|
||||
// Haetaan paikalliset mallit hubille lähetettäväksi
|
||||
let mut available_models = None;
|
||||
@@ -396,6 +443,13 @@ async fn main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merkitään yhdistetyksi TUI:ssa
|
||||
{
|
||||
let mut st = tui_state.write().await;
|
||||
st.status = "ACTIVE".to_string();
|
||||
st.push_log("Network", "Yhdistetty hubiin".to_string(), None);
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = cmd_rx.recv() => {
|
||||
@@ -418,6 +472,48 @@ async fn main() {
|
||||
st.status = "ACTIVE".to_string();
|
||||
st.push_log("System", "Suoritus jatkuu...".to_string(), None);
|
||||
}
|
||||
} else if cmd_str == "fetch_models" {
|
||||
// Haetaan mallit Ollamasta ja avataan valikkö
|
||||
if let Some(ref engine) = llm {
|
||||
match engine.fetch_models().await {
|
||||
Ok(tags) => {
|
||||
let models: Vec<String> = tags.get("models")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter()
|
||||
.filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
|
||||
.collect())
|
||||
.unwrap_or_default();
|
||||
let mut st = tui_state.write().await;
|
||||
st.model_picker_items = models;
|
||||
st.model_picker_idx = 0;
|
||||
st.model_picker_open = true;
|
||||
}
|
||||
Err(e) => {
|
||||
let mut st = tui_state.write().await;
|
||||
st.push_log("System", format!("Mallilistan haku epäonnistui: {}", e), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(model) = cmd_str.strip_prefix("change_model:") {
|
||||
// TUI:sta valittu malli — vaihdetaan
|
||||
if let Some(ref engine) = llm {
|
||||
engine.set_model(model.to_string());
|
||||
match engine.ensure_model().await {
|
||||
Ok(()) => {
|
||||
tracing::info!("Malli vaihdettu: {}", model);
|
||||
let mut st = tui_state.write().await;
|
||||
st.model_name = model.to_string();
|
||||
st.push_log("System", format!("Malli vaihdettu: {}", model), None);
|
||||
// Ilmoitetaan hubille
|
||||
let auth = build_auth_message(allocated_gb, model, available_models.clone());
|
||||
let _ = write.send(Message::Text(auth)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let mut st = tui_state.write().await;
|
||||
st.push_log("System", format!("Mallin vaihto epäonnistui: {}", e), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,6 +546,18 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Node joined → oma node_id
|
||||
if text.contains(r#""type":"node_joined""#) {
|
||||
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if let Some(nid) = msg.get("node_id").and_then(|v| v.as_u64()) {
|
||||
let mut st = tui_state.write().await;
|
||||
if st.node_id.is_none() {
|
||||
st.node_id = Some(nid);
|
||||
st.push_log("Network", format!("Node ID: #{}", nid), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Verkon globaali tila
|
||||
if text.contains(r#""type":"network_status""#) {
|
||||
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
@@ -568,9 +676,27 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Yhteys katkesi — nollataan TUI:n busy-tila
|
||||
{
|
||||
let mut st = tui_state.write().await;
|
||||
let lost_task = st.cur_task_id.clone();
|
||||
if let Some(tid) = lost_task {
|
||||
st.push_log("Network", format!("Tehtävä {} keskeytyi yhteyden katketessa", tid), None);
|
||||
}
|
||||
st.cur_task_id = None;
|
||||
st.cur_prompt = None;
|
||||
st.node_id = None;
|
||||
st.status = "RECONNECTING".to_string();
|
||||
st.push_log("Network", "Yhteys hubiin katkesi — yhdistetään uudelleen 5s...".to_string(), None);
|
||||
}
|
||||
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
|
||||
}
|
||||
Err(e) => {
|
||||
{
|
||||
let mut st = tui_state.write().await;
|
||||
st.status = "RECONNECTING".to_string();
|
||||
st.push_log("Network", format!("Yhdistäminen epäonnistui: {} — yritetään 5s...", e), None);
|
||||
}
|
||||
tracing::warn!("Hubiin yhdistäminen epäonnistui: {} — yritetään uudelleen 5s...", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crossterm::{
|
||||
event::{self, Event, EventStream, KeyCode},
|
||||
event::{Event, EventStream, KeyCode},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
@@ -21,6 +21,7 @@ pub struct LogEntry {
|
||||
pub ty: String,
|
||||
pub msg: String,
|
||||
pub speed: Option<f64>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
pub struct DashboardState {
|
||||
@@ -35,6 +36,12 @@ pub struct DashboardState {
|
||||
pub last_tokens_sec: f64,
|
||||
pub network_active_nodes: usize,
|
||||
pub network_total_tasks: u64,
|
||||
// VRAM-tila (ollama ps)
|
||||
pub vram_status: String,
|
||||
// Mallivalikko
|
||||
pub model_picker_open: bool,
|
||||
pub model_picker_items: Vec<String>,
|
||||
pub model_picker_idx: usize,
|
||||
}
|
||||
|
||||
impl DashboardState {
|
||||
@@ -51,11 +58,17 @@ impl DashboardState {
|
||||
last_tokens_sec: 0.0,
|
||||
network_active_nodes: 1, // oletetaan itsemme
|
||||
network_total_tasks: 0,
|
||||
vram_status: "Haetaan...".to_string(),
|
||||
model_picker_open: false,
|
||||
model_picker_items: Vec::new(),
|
||||
model_picker_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_log(&mut self, ty: &str, msg: String, speed: Option<f64>) {
|
||||
let now = chrono::Local::now().format("%H:%M:%S").to_string();
|
||||
self.logs.push(LogEntry {
|
||||
timestamp: now,
|
||||
ty: ty.to_string(),
|
||||
msg,
|
||||
speed,
|
||||
@@ -88,20 +101,53 @@ pub async fn run_dashboard(
|
||||
}
|
||||
ev = reader.next() => {
|
||||
if let Some(Ok(Event::Key(key))) = ev {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
// Palautetaan näyttö ja suljetaan ohjelma
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
std::process::exit(0);
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
KeyCode::Char('p') | KeyCode::Char('P') => {
|
||||
let _ = cmd_tx.send("pause".to_string());
|
||||
} 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());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::Char('s') => {
|
||||
let _ = cmd_tx.send("resume".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +185,7 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
|
||||
let body_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(7), // Yläosan info ja tehtävä
|
||||
Constraint::Length(8), // Yläosan info ja tehtävä
|
||||
Constraint::Min(0), // Lokit / Chat alas
|
||||
].as_ref())
|
||||
.split(chunks[1]);
|
||||
@@ -152,12 +198,38 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
|
||||
].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)
|
||||
// Vasen paneeli: Laitteisto, Malli & Verkosto — VRAM-rivi värikoodattu
|
||||
let vram_color = if st.vram_status.starts_with('✓') {
|
||||
Color::Green
|
||||
} else if st.vram_status.starts_with('◐') {
|
||||
Color::Yellow
|
||||
} else if st.vram_status.starts_with('✗') {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
|
||||
let info_lines = vec![
|
||||
ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::raw("🚀 Malli: "),
|
||||
ratatui::text::Span::styled(&st.model_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
]),
|
||||
ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::raw("🎮 VRAM: "),
|
||||
ratatui::text::Span::styled(&st.vram_status, Style::default().fg(vram_color)),
|
||||
]),
|
||||
ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::raw("💻 Järjestelmä: "),
|
||||
ratatui::text::Span::styled(&st.sys_info, Style::default().fg(Color::White)),
|
||||
]),
|
||||
ratatui::text::Line::from(format!(
|
||||
"📊 Tehdyt: {} | Nopeus: {:.1} t/s", st.tasks_completed, st.last_tokens_sec
|
||||
)),
|
||||
ratatui::text::Line::from(format!(
|
||||
"🌐 Verkosto: {} solmua | {} tehtävää", st.network_active_nodes, st.network_total_tasks
|
||||
)),
|
||||
];
|
||||
let left_panel = Paragraph::new(info_lines)
|
||||
.block(Block::default().title(" Laitteisto ja AI ").borders(Borders::ALL))
|
||||
.style(Style::default().fg(Color::White))
|
||||
.wrap(Wrap { trim: true });
|
||||
@@ -201,6 +273,8 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
|
||||
};
|
||||
|
||||
ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(&log.timestamp, Style::default().fg(Color::DarkGray)),
|
||||
ratatui::text::Span::raw(" "),
|
||||
ratatui::text::Span::styled(format!("{: <8}", log.ty), Style::default().fg(ty_color).add_modifier(Modifier::BOLD)),
|
||||
ratatui::text::Span::raw(" | "),
|
||||
ratatui::text::Span::styled(log.msg.clone(), Style::default().fg(Color::White)),
|
||||
@@ -214,10 +288,43 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
|
||||
|
||||
// --- Footer / Status ---
|
||||
let status_color = if st.status == "ACTIVE" { Color::Green } else { Color::Yellow };
|
||||
let status_text = format!(" Tila: {} | Komennot: [P] Pause / [R] Työhön / [Q] Sulje ", st.status);
|
||||
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"
|
||||
js-sys = "0.3.68"
|
||||
web-sys = { version = "0.3.68", features = [
|
||||
"Window",
|
||||
"Document",
|
||||
"HtmlElement",
|
||||
"WebSocket",
|
||||
"MessageEvent",
|
||||
"Performance",
|
||||
"console",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
"Headers",
|
||||
"ReadableStream",
|
||||
"ReadableStreamDefaultReader",
|
||||
] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
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"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
||||
tokenizers = { version = "0.19.1", default-features = false, features = ["unstable_wasm"] }
|
||||
rexie = "0.6"
|
||||
log = "0.4"
|
||||
candle-core = { version = "0.8" }
|
||||
candle-core = "0.8"
|
||||
candle-nn = "0.8"
|
||||
candle-transformers = "0.8"
|
||||
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::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicBool, Ordering};
|
||||
use burn::tensor::Tensor;
|
||||
use burn::backend::{Wgpu, NdArray};
|
||||
|
||||
pub mod storage;
|
||||
pub mod sampling;
|
||||
pub mod smollm;
|
||||
pub mod qwen;
|
||||
pub mod qwen_coder;
|
||||
pub mod phi3;
|
||||
pub mod burn_smollm;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! console_log {
|
||||
@@ -82,41 +77,6 @@ pub async fn worker_fetch(url: &str) -> Result<web_sys::Response, 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
|
||||
/// 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);
|
||||
SELECTED_TASK.store(task_id, Ordering::SeqCst);
|
||||
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");
|
||||
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 {
|
||||
// Vain SmolLM-solmut, ja vain yksi inferenssi kerrallaan
|
||||
if LLM_BUSY.load(Ordering::SeqCst) {
|
||||
// Ohitetaan — edellinen inferenssi vielä käynnissä
|
||||
} else if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
|
||||
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let model = task.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if !prompt.is_empty() && model == "smollm-135m" {
|
||||
LLM_BUSY.store(true, Ordering::SeqCst);
|
||||
let ws_for_async = ws_clone.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
smollm::run_smollm_inference(prompt, ws_for_async).await;
|
||||
LLM_BUSY.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if msg.contains("llm_prompt") && current_task == 2 && auto_on {
|
||||
// Qwen2.5-0.5B
|
||||
if LLM_BUSY.load(Ordering::SeqCst) {
|
||||
} else if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
|
||||
@@ -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") {
|
||||
console_log!("[DEBUG] llm_prompt vastaanotettu! current_task={}, busy={}", current_task, LLM_BUSY.load(Ordering::SeqCst));
|
||||
if current_task == 4 || current_task == 5 {
|
||||
@@ -385,17 +314,6 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
|
||||
}
|
||||
}
|
||||
} // 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") {
|
||||
// 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.
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Kipinä Agentic Network lokaali pikatesti
|
||||
# Tämä ohjelma käynnistää lokaalin Kipinä Hubin taustalle, ja heittää sen jälkeen näkyviin
|
||||
# visuaalisen Natiivisolmun Ratatui-ruudun yhdistäen sen automaattisesti siihen.
|
||||
# Kun poistut Ratatui-näytöstä (esim painamalla Q), niin skripti sammuttaa siististi
|
||||
# myös taustalla pyörivän lokaalin Hubin!
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "========================================="
|
||||
echo " 🔥 Kipinä Agentic Network - Local Run 🔥"
|
||||
echo "========================================="
|
||||
echo "Varmistetaan portin 3000 vapaus..."
|
||||
lsof -ti :3000 | xargs kill -9 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "Käynnistetään Kipinä Hub taustalle..."
|
||||
# Ohjataan Hubin logit erilliseen tiedostoon jottei se sotke näkymää!
|
||||
env STATIC_DIR=frontend/dist cargo run -p hub > hub-local.log 2>&1 &
|
||||
HUB_PID=$!
|
||||
|
||||
# Odotellaan, että Hub saa portit kuunteluun
|
||||
sleep 2
|
||||
|
||||
echo "Käynnistetään Natiivisolmu ja Ratatui-dashboard..."
|
||||
# Käynnistetään TUI ja pakotetaan yhdistämään lokaaliin Hubiin.
|
||||
env -u OLLAMA_MODEL HUB_URL=ws://127.0.0.1:3000/ws cargo run -p native-node
|
||||
|
||||
# Kun TUI ohjelmasta on poistuttu
|
||||
echo ""
|
||||
echo "Dashboard suljettu! Ajetaan lokaali Hub (#$HUB_PID) siististi alas..."
|
||||
kill $HUB_PID
|
||||
echo "Kaikki sammutettu. Kiitos!"
|
||||
513
network-poc/tests/model-benchmark.mjs
Normal file
513
network-poc/tests/model-benchmark.mjs
Normal file
@@ -0,0 +1,513 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Kipinä Model Benchmark
|
||||
*
|
||||
* Generoi projekteja eri Ollama-malleilla ja testaa niiden toimivuus.
|
||||
* Käyttö:
|
||||
* node model-benchmark.mjs # kaikki mallit, oletusskenaario
|
||||
* node model-benchmark.mjs --models qwen3:8b,qwen3:30b
|
||||
* node model-benchmark.mjs --ollama http://host:11434
|
||||
* node model-benchmark.mjs --scenarios all # kaikki skenaariot
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
|
||||
// === CLI-argumentit ===
|
||||
const args = process.argv.slice(2);
|
||||
function arg(name, fallback) {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||
}
|
||||
const OLLAMA_URL = arg('ollama', process.env.OLLAMA_URL || 'http://localhost:11434');
|
||||
const HUB_URL = arg('hub', ''); // Vaihtoehto: --hub https://kipina.studio
|
||||
const FILTER_MODELS = arg('models', '');
|
||||
const SCENARIO_FILTER = arg('scenarios', 'default');
|
||||
const OUTPUT_DIR = arg('output', '/tmp/kipina-benchmark');
|
||||
const MAX_FIX_ROUNDS = 2;
|
||||
|
||||
// === Ollama / Hub -client ===
|
||||
async function ollamaChat(model, prompt, systemPrompt, maxTokens = 2048) {
|
||||
const start = Date.now();
|
||||
|
||||
if (HUB_URL) {
|
||||
// Hub-reitti: /api/v1/chat/completions
|
||||
const taskId = `bench-${Date.now()}-${Math.random().toString(36).slice(2,8)}`;
|
||||
const resp = await fetch(`${HUB_URL}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, prompt, task_id: taskId, system_prompt: systemPrompt, max_tokens: maxTokens }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Hub HTTP ${resp.status}: ${await resp.text()}`);
|
||||
const data = await resp.json();
|
||||
const elapsed = Date.now() - start;
|
||||
return {
|
||||
text: (data.response || '').trim(),
|
||||
tokens: data.tokens_generated || 0,
|
||||
durationMs: elapsed,
|
||||
tokPerSec: data.tokens_per_sec || (data.tokens_generated || 0) / (elapsed / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// Suora Ollama-reitti: /api/chat
|
||||
const messages = [];
|
||||
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const resp = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
options: { num_predict: maxTokens, temperature: 0.7, top_k: 40, repeat_penalty: 1.15 },
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Ollama HTTP ${resp.status}: ${await resp.text()}`);
|
||||
const data = await resp.json();
|
||||
const elapsed = Date.now() - start;
|
||||
const text = (data.message?.content || '').trim();
|
||||
const evalCount = data.eval_count || 0;
|
||||
const evalDurationNs = data.eval_duration || 1;
|
||||
const tokPerSec = evalCount / (evalDurationNs / 1e9);
|
||||
return { text, tokens: evalCount, durationMs: elapsed, tokPerSec };
|
||||
}
|
||||
|
||||
async function ollamaListModels() {
|
||||
const url = HUB_URL ? `${HUB_URL}/api/v1/ollama/tags` : `${OLLAMA_URL}/api/tags`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`Tags: HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return (data.models || []).map(m => m.name);
|
||||
}
|
||||
|
||||
// === Promptit (kopioitu index.astrosta) ===
|
||||
const CLIENT_SYSTEM = `You are a product owner who turns vague ideas into clear, actionable software requirements.
|
||||
|
||||
GIVEN a short project description from the user, produce a structured brief:
|
||||
|
||||
1. PROJECT NAME: a short, descriptive name
|
||||
2. GOAL: one sentence explaining what the software does and who it's for
|
||||
3. CORE FEATURES: numbered list of 3-8 concrete features (not vague wishes)
|
||||
4. DATA MODEL: list the main entities and their key fields (include field types)
|
||||
5. API ENDPOINTS: list the REST endpoints (method + path + purpose)
|
||||
6. CONSTRAINTS: any technical constraints (e.g. "must use SQLite", "no auth needed")
|
||||
|
||||
RULES:
|
||||
- Be specific: "User can filter todos by status" not "todo management"
|
||||
- Use plain English, no code
|
||||
- Maximum 400 words total`;
|
||||
|
||||
const SPEC_SYSTEM = `You are a software architect who designs database schemas for Python web applications.
|
||||
|
||||
THINK STEP BY STEP before outputting JSON:
|
||||
1. What are the main ENTITIES (nouns) in this project?
|
||||
2. What FIELDS does each entity need? (name, type, required?)
|
||||
3. Which entities REFERENCE each other? (e.g. "a Book belongs to an Author" → Book has author_id)
|
||||
4. Are there Date/DateTime fields? → add extra_imports
|
||||
|
||||
Then output ONLY valid JSON (no explanations before or after).
|
||||
|
||||
SCHEMA:
|
||||
{"project_name":"short-name","description":"One sentence","entities":[{"name":"EntityName","table_name":"entity_names","fields":[{"name":"field_name","sa_type":"String(255)","py_type":"str","nullable":false,"default":null}]}],"relationships":[{"from":"ChildEntity","field":"parent_id","to":"ParentEntity","type":"many-to-one"}],"extra_imports":[]}
|
||||
|
||||
FIELD RULES:
|
||||
- sa_type: String(N), Text, Integer, Date, DateTime, Boolean, Float
|
||||
- py_type: str, int, float, bool, date, datetime — append " | None" if nullable
|
||||
- Status fields: use String(20) with default value, NEVER Enum
|
||||
- Every entity gets "id" automatically — do NOT add id or redundant ID fields
|
||||
- Use snake_case for field names
|
||||
|
||||
RELATIONSHIP RULES:
|
||||
- If entity A "belongs to" entity B → A has b_id field (Integer, nullable=false) + relationship entry
|
||||
- EVERY _id field MUST have a matching relationship entry
|
||||
- Parent entities must appear BEFORE children in the entities array
|
||||
- If no relationships, set "relationships": []
|
||||
|
||||
AVOID: redundant ID fields, generic names, more than 7 fields or 3 entities, non-English entity/field names (ALWAYS English even if description is Finnish)
|
||||
|
||||
EXAMPLES (adapt, don't copy):
|
||||
Todo app → Todo: title(str), description(Text|None), due_date(Date|None), status(String20="pending")
|
||||
Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_id→Author, published_at(DateTime|None), status(String20="draft")`;
|
||||
|
||||
const FIX_SYSTEM = 'You are a Python code fixer. Return ONLY the corrected Python file. No markdown fences, no explanations — just valid Python code.';
|
||||
|
||||
// === Template-funktiot (kopioitu korjatusta index.astrosta) ===
|
||||
function pyLiteral(val) {
|
||||
if (val === true) return 'True';
|
||||
if (val === false) return 'False';
|
||||
if (val === null || val === undefined) return 'None';
|
||||
if (typeof val === 'string') return `"${val}"`;
|
||||
return String(val);
|
||||
}
|
||||
function pyJsonLiteral(obj) {
|
||||
const parts = Object.entries(obj).map(([k, v]) => {
|
||||
let pyVal;
|
||||
if (v === true) pyVal = 'True'; else if (v === false) pyVal = 'False';
|
||||
else if (v === null) pyVal = 'None'; else if (typeof v === 'string') pyVal = `"${v}"`;
|
||||
else pyVal = String(v);
|
||||
return `"${k}":${pyVal}`;
|
||||
});
|
||||
return '{' + parts.join(',') + '}';
|
||||
}
|
||||
function tmplModels(spec) {
|
||||
const saTypes = new Set(['Integer']);
|
||||
for (const e of spec.entities) for (const f of e.fields) saTypes.add(f.sa_type.match(/^(\w+)/)[1]);
|
||||
const relMap = {};
|
||||
for (const r of (spec.relationships || [])) {
|
||||
const target = spec.entities.find(e => e.name === r.to);
|
||||
if (target) relMap[`${r.from}.${r.field}`] = target.table_name;
|
||||
}
|
||||
if (Object.keys(relMap).length > 0) saTypes.add('ForeignKey');
|
||||
const imports = [...saTypes].sort().join(', ');
|
||||
let code = `from sqlalchemy import create_engine, Column, ${imports}\nfrom sqlalchemy.orm import declarative_base, sessionmaker\n\nDATABASE_URL = "sqlite:///./app.db"\nengine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})\nSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\nBase = declarative_base()\n\n`;
|
||||
for (const e of spec.entities) {
|
||||
code += `class ${e.name}(Base):\n __tablename__ = "${e.table_name}"\n id = Column(Integer, primary_key=True, index=True)\n`;
|
||||
for (const f of e.fields) {
|
||||
const fkTarget = relMap[`${e.name}.${f.name}`];
|
||||
let parts = fkTarget ? [`Column(${f.sa_type}, ForeignKey("${fkTarget}.id")`] : [`Column(${f.sa_type}`];
|
||||
if (!f.nullable) parts.push('nullable=False');
|
||||
if (f.default !== null && f.default !== undefined) parts.push(`default=${pyLiteral(f.default)}`);
|
||||
code += ` ${f.name} = ${parts.join(', ')})\n`;
|
||||
}
|
||||
code += '\n';
|
||||
}
|
||||
code += 'Base.metadata.create_all(bind=engine)\n';
|
||||
return code;
|
||||
}
|
||||
function tmplSchemas(spec) {
|
||||
const dtTypes = new Set();
|
||||
for (const e of spec.entities) for (const f of e.fields) {
|
||||
if (/\bdate\b/i.test(f.py_type) && !/datetime/.test(f.py_type)) dtTypes.add('date');
|
||||
if (/\bdatetime\b/i.test(f.py_type)) dtTypes.add('datetime');
|
||||
}
|
||||
let code = 'from pydantic import BaseModel, ConfigDict\n';
|
||||
if (dtTypes.size > 0) code += `from datetime import ${[...dtTypes].sort().join(', ')}\n`;
|
||||
for (const imp of (spec.extra_imports || [])) {
|
||||
if (/^(date|datetime)$/.test(imp.trim())) continue;
|
||||
if (/^from\s/.test(imp) || /^import\s/.test(imp)) code += imp + '\n';
|
||||
}
|
||||
code += '\n';
|
||||
for (const e of spec.entities) {
|
||||
code += `class ${e.name}Create(BaseModel):\n`;
|
||||
for (const f of e.fields) {
|
||||
if (f.default !== null && f.default !== undefined) code += ` ${f.name}: ${f.py_type} = ${pyLiteral(f.default)}\n`;
|
||||
else if (f.nullable && f.py_type.includes('None')) code += ` ${f.name}: ${f.py_type} = None\n`;
|
||||
else code += ` ${f.name}: ${f.py_type}\n`;
|
||||
}
|
||||
code += `\nclass ${e.name}Response(${e.name}Create):\n id: int\n model_config = ConfigDict(from_attributes=True)\n\n`;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
function tmplMain(spec) {
|
||||
const modelNames = spec.entities.map(e => e.name).join(', ');
|
||||
const createNames = spec.entities.map(e => e.name+'Create').join(', ');
|
||||
const responseNames = spec.entities.map(e => e.name+'Response').join(', ');
|
||||
let code = `from fastapi import FastAPI, Depends, HTTPException\nfrom sqlalchemy.orm import Session\nfrom models import Base, engine, SessionLocal, ${modelNames}\nfrom schemas import ${createNames}, ${responseNames}\n\napp = FastAPI()\n\ndef get_db():\n db = SessionLocal()\n try:\n yield db\n finally:\n db.close()\n\n`;
|
||||
for (const e of spec.entities) {
|
||||
const lo = e.name.toLowerCase(), tb = e.table_name;
|
||||
code += `@app.post("/${tb}/", response_model=${e.name}Response, status_code=201)\ndef create_${lo}(item: ${e.name}Create, db: Session = Depends(get_db)):\n db_item = ${e.name}(**item.model_dump())\n db.add(db_item)\n db.commit()\n db.refresh(db_item)\n return db_item\n\n`;
|
||||
code += `@app.get("/${tb}/", response_model=list[${e.name}Response])\ndef list_${lo}s(db: Session = Depends(get_db)):\n return db.query(${e.name}).all()\n\n`;
|
||||
code += `@app.get("/${tb}/{item_id}", response_model=${e.name}Response)\ndef get_${lo}(item_id: int, db: Session = Depends(get_db)):\n item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n if not item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n return item\n\n`;
|
||||
code += `@app.put("/${tb}/{item_id}", response_model=${e.name}Response)\ndef update_${lo}(item_id: int, item: ${e.name}Create, db: Session = Depends(get_db)):\n db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n if not db_item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n for key, value in item.model_dump().items():\n setattr(db_item, key, value)\n db.commit()\n db.refresh(db_item)\n return db_item\n\n`;
|
||||
code += `@app.delete("/${tb}/{item_id}", status_code=204)\ndef delete_${lo}(item_id: int, db: Session = Depends(get_db)):\n db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n if not db_item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n db.delete(db_item)\n db.commit()\n\n`;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
function tmplTests(spec) {
|
||||
let code = `from fastapi.testclient import TestClient\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import sessionmaker\nfrom main import app, get_db\nfrom models import Base\n\nTEST_DB = "sqlite:///./test.db"\ntest_engine = create_engine(TEST_DB, connect_args={"check_same_thread": False})\nTestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)\nBase.metadata.create_all(bind=test_engine)\n\ndef override_get_db():\n db = TestSession()\n try:\n yield db\n finally:\n db.close()\n\napp.dependency_overrides[get_db] = override_get_db\nclient = TestClient(app)\n\n`;
|
||||
for (const e of spec.entities) {
|
||||
const lo = e.name.toLowerCase(), tb = e.table_name;
|
||||
const testData = {};
|
||||
for (const f of e.fields) {
|
||||
if (f.default !== null && f.default !== undefined) { testData[f.name] = f.default; continue; }
|
||||
if (f.py_type.includes('str')) testData[f.name] = `Test ${f.name}`;
|
||||
else if (f.py_type.includes('int')) testData[f.name] = 1;
|
||||
else if (f.py_type.includes('float')) testData[f.name] = 1.0;
|
||||
else if (f.py_type.includes('bool')) testData[f.name] = true;
|
||||
else if (f.py_type.includes('date')) testData[f.name] = '2024-01-15';
|
||||
}
|
||||
const td = pyJsonLiteral(testData);
|
||||
const firstStr = e.fields.find(f => f.py_type.includes('str') && f.name !== 'status');
|
||||
const updateData = {...testData};
|
||||
if (firstStr) updateData[firstStr.name] = `Updated ${firstStr.name}`;
|
||||
const ud = pyJsonLiteral(updateData);
|
||||
code += `def test_create_${lo}():\n response = client.post('/${tb}/', json=${td})\n assert response.status_code == 201\n assert 'id' in response.json()\n\n`;
|
||||
code += `def test_list_${lo}s():\n client.post('/${tb}/', json=${td})\n response = client.get('/${tb}/')\n assert response.status_code == 200\n assert len(response.json()) >= 1\n\n`;
|
||||
code += `def test_get_${lo}_by_id():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.get(f'/${tb}/{item_id}')\n assert response.status_code == 200\n assert response.json()['id'] == item_id\n\n`;
|
||||
code += `def test_get_${lo}_not_found():\n response = client.get('/${tb}/99999')\n assert response.status_code == 404\n\n`;
|
||||
code += `def test_update_${lo}():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.put(f'/${tb}/{item_id}', json=${ud})\n assert response.status_code == 200\n\n`;
|
||||
code += `def test_delete_${lo}():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.delete(f'/${tb}/{item_id}')\n assert response.status_code == 204\n response = client.get(f'/${tb}/{item_id}')\n assert response.status_code == 404\n\n`;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
function tmplPyproject(spec) {
|
||||
const name = (spec.project_name || 'app').toLowerCase().replace(/\s+/g, '-');
|
||||
return `[project]\nname = "${name}"\nversion = "0.1.0"\nrequires-python = ">=3.11"\ndependencies = [\n "fastapi",\n "uvicorn[standard]",\n "sqlalchemy",\n "pytest",\n "httpx",\n]\n`;
|
||||
}
|
||||
|
||||
// === Validaattori ===
|
||||
function validateProjectCode(files) {
|
||||
const issues = [];
|
||||
for (const [fname, code] of Object.entries(files)) {
|
||||
if (!fname.endsWith('.py')) continue;
|
||||
const lines = code.split('\n');
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^from\s+\.(\w*)\s+import/);
|
||||
if (m) issues.push(`ISSUE: ${fname}: relatiivinen import`);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^from\s+(models|schemas|main)\s+import\s+(.+)/);
|
||||
if (!m) continue;
|
||||
const srcCode = files[m[1] + '.py'];
|
||||
if (!srcCode) { issues.push(`ISSUE: ${fname}: ${m[1]}.py puuttuu`); continue; }
|
||||
const names = m[2].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
|
||||
for (const name of names) {
|
||||
if (name && !srcCode.includes(name)) issues.push(`ISSUE: ${fname}: "${name}" puuttuu ${m[1]}.py:stä`);
|
||||
}
|
||||
}
|
||||
if (fname === 'schemas.py') {
|
||||
if (/:\s*date\b/.test(code) && !/from datetime import/.test(code))
|
||||
issues.push('ISSUE: schemas.py: date-import puuttuu');
|
||||
if (/:\s*datetime\b/.test(code) && !/from datetime import/.test(code))
|
||||
issues.push('ISSUE: schemas.py: datetime-import puuttuu');
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
|
||||
if (/(?<!["\w])false(?![\w"])/.test(line)) issues.push(`ISSUE: ${fname}:${i+1}: "false" → "False"`);
|
||||
if (/(?<!["\w])true(?![\w"])/.test(line)) issues.push(`ISSUE: ${fname}:${i+1}: "true" → "True"`);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function extractJson(text) {
|
||||
const m = text.match(/```(?:json)?\s*\n([\s\S]*?)```/);
|
||||
if (m) text = m[1].trim();
|
||||
let depth = 0, start = null;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '{') { if (depth === 0) start = i; depth++; }
|
||||
else if (text[i] === '}') { depth--; if (depth === 0 && start !== null) { try { return JSON.parse(text.slice(start, i+1)); } catch(e) { continue; } } }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// === Testiskenaariot ===
|
||||
const SCENARIOS = [
|
||||
{ id: 'todo', prompt: 'Todo-sovellus: tehtävien hallinta, deadline, prioriteetti ja status' },
|
||||
{ id: 'users', prompt: 'REST API käyttäjähallinnalle SQLite-tietokannalla' },
|
||||
{ id: 'blog', prompt: 'Blogi-API: kirjoittajat ja artikkelit, julkaisupäivämäärä ja status' },
|
||||
];
|
||||
|
||||
// === Pipeline: yhdelle mallille ja skenaariolle ===
|
||||
async function runPipeline(model, scenario) {
|
||||
const result = {
|
||||
model, scenario: scenario.id,
|
||||
reqOk: false, specOk: false, specEntities: 0,
|
||||
validationIssues: 0, fixRounds: 0,
|
||||
testsTotal: 0, testsPassed: 0, testsFailed: 0,
|
||||
totalDurationMs: 0, totalTokens: 0, avgTokPerSec: 0,
|
||||
error: null,
|
||||
};
|
||||
const timings = [];
|
||||
const dir = `${OUTPUT_DIR}/${model.replace(/[/:]/g, '_')}__${scenario.id}`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
try {
|
||||
// 1. Vaatimukset
|
||||
console.log(` [1/5] Vaatimukset...`);
|
||||
const req = await ollamaChat(model, scenario.prompt, CLIENT_SYSTEM, 1024);
|
||||
timings.push(req);
|
||||
if (!req.text || req.text.length < 50) { result.error = 'Vaatimukset liian lyhyet'; return result; }
|
||||
result.reqOk = true;
|
||||
writeFileSync(`${dir}/_requirements.txt`, req.text);
|
||||
|
||||
// 2. JSON-speksi
|
||||
console.log(` [2/5] JSON-speksi...`);
|
||||
const specResp = await ollamaChat(model, `${req.text}\n\nOutput a JSON spec for this project.`, SPEC_SYSTEM, 2048);
|
||||
timings.push(specResp);
|
||||
const spec = extractJson(specResp.text);
|
||||
if (!spec || !spec.entities || spec.entities.length === 0) { result.error = 'JSON-speksi epäonnistui'; writeFileSync(`${dir}/_spec_raw.txt`, specResp.text); return result; }
|
||||
result.specOk = true;
|
||||
result.specEntities = spec.entities.length;
|
||||
writeFileSync(`${dir}/_spec.json`, JSON.stringify(spec, null, 2));
|
||||
|
||||
// 3. Template-generointi
|
||||
console.log(` [3/5] Koodigenerointi...`);
|
||||
const files = {
|
||||
'models.py': tmplModels(spec),
|
||||
'schemas.py': tmplSchemas(spec),
|
||||
'main.py': tmplMain(spec),
|
||||
'test_main.py': tmplTests(spec),
|
||||
'pyproject.toml': tmplPyproject(spec),
|
||||
};
|
||||
|
||||
// 4. Validointi + korjaussilmukka
|
||||
let issues = validateProjectCode(files);
|
||||
let fixRound = 0;
|
||||
while (issues.length > 0 && fixRound < MAX_FIX_ROUNDS) {
|
||||
fixRound++;
|
||||
console.log(` [4/5] Korjauskierros ${fixRound} (${issues.length} ongelmaa)...`);
|
||||
const issuesByFile = {};
|
||||
for (const issue of issues) {
|
||||
const m = issue.match(/^ISSUE:\s*(\S+?):/);
|
||||
const fname = m ? m[1] : 'unknown';
|
||||
if (!issuesByFile[fname]) issuesByFile[fname] = [];
|
||||
issuesByFile[fname].push(issue);
|
||||
}
|
||||
for (const [fname, fIssues] of Object.entries(issuesByFile)) {
|
||||
if (!files[fname]) continue;
|
||||
const fixPrompt = `Fix the following issues in this Python file. Return ONLY the complete corrected file, no explanations.\n\nISSUES:\n${fIssues.join('\n')}\n\nCURRENT FILE (${fname}):\n\`\`\`python\n${files[fname]}\`\`\``;
|
||||
const fixResp = await ollamaChat(model, fixPrompt, FIX_SYSTEM, 2048);
|
||||
timings.push(fixResp);
|
||||
if (fixResp.text) {
|
||||
files[fname] = fixResp.text.replace(/^```(?:python)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim() + '\n';
|
||||
}
|
||||
}
|
||||
issues = validateProjectCode(files);
|
||||
}
|
||||
result.validationIssues = issues.length;
|
||||
result.fixRounds = fixRound;
|
||||
|
||||
// Kirjoita tiedostot levylle
|
||||
for (const [fn, content] of Object.entries(files)) writeFileSync(`${dir}/${fn}`, content);
|
||||
|
||||
// 5. Pytest
|
||||
console.log(` [5/5] Pytest...`);
|
||||
try {
|
||||
const uvPath = process.env.HOME + '/.local/bin/uv';
|
||||
const uv = existsSync(uvPath) ? uvPath : 'uv';
|
||||
execSync(`cd "${dir}" && ${uv} sync 2>/dev/null`, { timeout: 60000, stdio: 'pipe' });
|
||||
execSync(`cd "${dir}" && rm -f app.db test.db`, { stdio: 'pipe' });
|
||||
const pytestOut = execSync(`cd "${dir}" && ${uv} run pytest test_main.py -v --tb=short 2>&1`, { timeout: 60000, encoding: 'utf-8' });
|
||||
writeFileSync(`${dir}/_pytest.txt`, pytestOut);
|
||||
|
||||
const passedMatch = pytestOut.match(/(\d+) passed/);
|
||||
const failedMatch = pytestOut.match(/(\d+) failed/);
|
||||
result.testsPassed = passedMatch ? parseInt(passedMatch[1]) : 0;
|
||||
result.testsFailed = failedMatch ? parseInt(failedMatch[1]) : 0;
|
||||
result.testsTotal = result.testsPassed + result.testsFailed;
|
||||
} catch (e) {
|
||||
const output = e.stdout || e.stderr || e.message || '';
|
||||
writeFileSync(`${dir}/_pytest.txt`, output);
|
||||
const passedMatch = output.match(/(\d+) passed/);
|
||||
const failedMatch = output.match(/(\d+) failed/);
|
||||
const errorMatch = output.match(/(\d+) error/);
|
||||
result.testsPassed = passedMatch ? parseInt(passedMatch[1]) : 0;
|
||||
result.testsFailed = (failedMatch ? parseInt(failedMatch[1]) : 0) + (errorMatch ? parseInt(errorMatch[1]) : 0);
|
||||
result.testsTotal = result.testsPassed + result.testsFailed;
|
||||
if (result.testsTotal === 0) result.error = 'Pytest kaatui';
|
||||
}
|
||||
} catch (e) {
|
||||
result.error = e.message;
|
||||
}
|
||||
|
||||
// Yhteenveto
|
||||
result.totalDurationMs = timings.reduce((s, t) => s + t.durationMs, 0);
|
||||
result.totalTokens = timings.reduce((s, t) => s + t.tokens, 0);
|
||||
result.avgTokPerSec = timings.length > 0 ? timings.reduce((s, t) => s + t.tokPerSec, 0) / timings.length : 0;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// === Main ===
|
||||
async function main() {
|
||||
console.log('╔══════════════════════════════════════════════╗');
|
||||
console.log('║ Kipinä Model Benchmark ║');
|
||||
console.log('╚══════════════════════════════════════════════╝');
|
||||
console.log(`Ollama: ${OLLAMA_URL}`);
|
||||
|
||||
// Haetaan mallit
|
||||
let models;
|
||||
try {
|
||||
models = await ollamaListModels();
|
||||
} catch (e) {
|
||||
console.error(`Ei yhteyttä Ollamaan (${OLLAMA_URL}): ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (FILTER_MODELS) {
|
||||
const filter = FILTER_MODELS.split(',').map(s => s.trim());
|
||||
models = models.filter(m => filter.some(f => m.includes(f)));
|
||||
}
|
||||
|
||||
console.log(`Mallit (${models.length}): ${models.join(', ')}`);
|
||||
|
||||
const scenarios = SCENARIO_FILTER === 'all' ? SCENARIOS : [SCENARIOS[0]];
|
||||
console.log(`Skenaariot (${scenarios.length}): ${scenarios.map(s => s.id).join(', ')}`);
|
||||
console.log(`Tulokset: ${OUTPUT_DIR}/`);
|
||||
console.log('');
|
||||
|
||||
// Puhdista output
|
||||
rmSync(OUTPUT_DIR, { recursive: true, force: true });
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const model of models) {
|
||||
for (const scenario of scenarios) {
|
||||
console.log(`\n━━━ ${model} × ${scenario.id} ━━━`);
|
||||
const r = await runPipeline(model, scenario);
|
||||
results.push(r);
|
||||
|
||||
const status = r.error ? `✗ ${r.error}` :
|
||||
r.testsPassed === r.testsTotal && r.testsTotal > 0 ? `✓ ${r.testsPassed}/${r.testsTotal}` :
|
||||
`◐ ${r.testsPassed}/${r.testsTotal}`;
|
||||
console.log(` → ${status} | ${(r.totalDurationMs/1000).toFixed(1)}s | ${r.totalTokens} tok | ${r.avgTokPerSec.toFixed(1)} tok/s`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Tulostaulu ===
|
||||
console.log('\n\n╔══════════════════════════════════════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ TULOKSET ║');
|
||||
console.log('╠══════════════════════════════════════════════════════════════════════════════════════════════════╣');
|
||||
|
||||
const header = [
|
||||
'Malli'.padEnd(40),
|
||||
'Skenaario'.padEnd(10),
|
||||
'Speksi'.padEnd(8),
|
||||
'Testit'.padEnd(10),
|
||||
'Korjaus'.padEnd(8),
|
||||
'Aika'.padEnd(8),
|
||||
'tok/s'.padEnd(8),
|
||||
'Tulos',
|
||||
].join(' │ ');
|
||||
console.log(`║ ${header} ║`);
|
||||
console.log('╠' + '═'.repeat(header.length + 2) + '╣');
|
||||
|
||||
for (const r of results) {
|
||||
const specStatus = r.specOk ? `✓ ${r.specEntities}e` : '✗';
|
||||
const testStatus = r.testsTotal > 0 ? `${r.testsPassed}/${r.testsTotal}` : '-';
|
||||
const fixStatus = r.fixRounds > 0 ? `${r.fixRounds}×` : '-';
|
||||
const time = `${(r.totalDurationMs/1000).toFixed(0)}s`;
|
||||
const speed = `${r.avgTokPerSec.toFixed(0)}`;
|
||||
const verdict = r.error ? '✗ FAIL' : r.testsPassed === r.testsTotal && r.testsTotal > 0 ? '✓ PASS' : '◐ PARTIAL';
|
||||
|
||||
const row = [
|
||||
r.model.padEnd(40),
|
||||
r.scenario.padEnd(10),
|
||||
specStatus.padEnd(8),
|
||||
testStatus.padEnd(10),
|
||||
fixStatus.padEnd(8),
|
||||
time.padEnd(8),
|
||||
speed.padEnd(8),
|
||||
verdict,
|
||||
].join(' │ ');
|
||||
console.log(`║ ${row} ║`);
|
||||
}
|
||||
console.log('╚' + '═'.repeat(header.length + 2) + '╝');
|
||||
|
||||
// Tallenna JSON
|
||||
writeFileSync(`${OUTPUT_DIR}/results.json`, JSON.stringify(results, null, 2));
|
||||
console.log(`\nJSON: ${OUTPUT_DIR}/results.json`);
|
||||
|
||||
// Yhteenveto
|
||||
const passed = results.filter(r => !r.error && r.testsPassed === r.testsTotal && r.testsTotal > 0);
|
||||
const partial = results.filter(r => !r.error && r.testsPassed < r.testsTotal && r.testsTotal > 0);
|
||||
const failed = results.filter(r => r.error || r.testsTotal === 0);
|
||||
console.log(`\n✓ PASS: ${passed.length} | ◐ PARTIAL: ${partial.length} | ✗ FAIL: ${failed.length} | Yhteensä: ${results.length}`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
BIN
projektit/luodut/rest-api-kyttjhallinnalle (1).zip
Normal file
BIN
projektit/luodut/rest-api-kyttjhallinnalle (1).zip
Normal file
Binary file not shown.
BIN
projektit/luodut/rest-api-kyttjhallinnalle.zip
Normal file
BIN
projektit/luodut/rest-api-kyttjhallinnalle.zip
Normal file
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ää!
|
||||
|
||||
---
|
||||
|
||||
Submodule projektit/projektiopinnot-1-datan-hallinta-ttm23sai added at c20e918b34
Reference in New Issue
Block a user