19 Commits

Author SHA1 Message Date
Jaakko Vanhala
094b183c17 toimii suht ok 2026-04-12 08:02:17 +03:00
Jaakko Vanhala
a91b9539b3 Promptin generointiin muutoksia 2026-04-12 07:43:59 +03:00
Jaakko Vanhala
6e2f85daa8 Lisätty *.log gitignoreen, poistettu native-node.log seurannasta 2026-04-12 07:41:34 +03:00
Jaakko Vanhala
466e61d730 Cache-busting: kipina-node lataus- ja asennusskripti ohittaa välimuistin
StatusBar ja kipina-node-skripti käyttävät ?v=timestamp-parametria
välimuistin ohittamiseen. Binäärin uudelleenlataus oletuksena Y.
deploy-binaries.sh kopioi myös kipina-node-skriptin palvelimelle.
2026-04-12 07:40:33 +03:00
Jaakko Vanhala
5f00582053 UI:n system prompt ja sampling-parametrit välittyvät inferenssiin asti
Frontend lähettää agentin asetukset (system_prompt, temperature, top_k,
max_tokens, repeat_penalty, stop) API:lle. Hub välittää ne solmulle.
Native-node ja Wasm-coder käyttävät välitettyjä arvoja hardkoodattujen
sijaan.
2026-04-12 07:39:41 +03:00
Jaakko Vanhala
e272b0d124 TUI build korjattu 2026-04-12 06:43:12 +03:00
Jaakko Vanhala
d3affb3a09 TUI again 2026-04-12 06:33:10 +03:00
Jaakko Vanhala
1377e72f78 TUI inc 2026-04-12 06:26:34 +03:00
Jaakko Vanhala
403f35efdc TUI inc 2026-04-12 06:22:52 +03:00
Jaakko Vanhala
ce0ccbddd3 Jotain jännää 2026-04-11 19:17:48 +03:00
Jaakko Vanhala
80806498e0 Remote start stop control 2026-04-11 19:14:20 +03:00
Jaakko Vanhala
660e80c2bc natiivinodehommajuttuja 2026-04-11 18:14:08 +03:00
Jaakko Vanhala
591cfcb04b Päivitetyt kipina-node-binäärit: macOS, Linux x86/ARM, Windows
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:04:53 +03:00
Jaakko Vanhala
3cda57f0bc Hub: solmujen mallilistaus muistiin + /api/tags palauttaa verkon mallit
Natiivisolmun auth-viestistä tallennetaan mallilistaus node_models-mappiin.
/api/tags priorisoi verkon solmujen malleja lokaalin Ollaman edelle.
api_hardware käyttää tietokannan litteää rakennetta.

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

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

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

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

3
.gitignore vendored
View File

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

View File

@@ -4,35 +4,60 @@ 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/3] macOS ARM64..."
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/3] Linux x86_64..."
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 2>&1 | tail -1 && cp target/release/native-node /app/frontend/public/download/kipina-node-linux-x86_64"
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/3] Linux ARM64..."
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 2>&1 | tail -1 && cp target/release/native-node /app/frontend/public/download/kipina-node-linux-arm64"
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-*

30
network-poc/deploy-binaries.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/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 ==="

View File

@@ -0,0 +1,13 @@
#!/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"

View File

@@ -40,20 +40,20 @@ echo "[1/4] Rakennetaan image lokaalisti..."
docker build --platform linux/amd64 -f Dockerfile.prod -t kipina-agentic:latest .
# 2. Tallennetaan tiedostoon
echo "[2/5] Pakataan image..."
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/5] 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/5] Ladataan image palvelimella..."
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 "[5/5] Käynnistetään palvelut uudelleen..."
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 ==="

View File

@@ -0,0 +1 @@
5f005820535910a5052a33cfcfc0bd6909d11c25

Binary file not shown.

View File

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

View File

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

View File

@@ -71,7 +71,25 @@ import Settings from "../components/Settings.astro";
// === Globaalit tilat ===
const defaultAgents = {
manager: { name: 'Manageri', avatar: '/avatars/karhunpentu.webp', model: 'qwen-coder', order: 0,
client: { name: 'Asiakas', avatar: '/avatars/kettu_notext.webp', model: 'qwen-coder', order: 0,
temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are a product owner who turns vague ideas into clear, actionable software requirements.
GIVEN a short project description from the user, produce a structured brief:
1. PROJECT NAME: a short, descriptive name
2. GOAL: one sentence explaining what the software does and who it's for
3. CORE FEATURES: numbered list of 3-5 concrete features (not vague wishes)
4. DATA MODEL: list the main entities and their key fields
5. API ENDPOINTS: list the essential REST endpoints (method + path + purpose)
6. CONSTRAINTS: any technical constraints (e.g. "must use SQLite", "no auth needed for MVP")
RULES:
- Be specific: "User can filter todos by status" not "todo management"
- Keep scope small — MVP only, no nice-to-haves
- Use plain English, no code
- Maximum 200 words total` },
manager: { name: 'Manageri', avatar: '/avatars/karhunpentu.webp', model: 'qwen-coder', order: 1,
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are a senior project manager and software architect. Your job is to plan the file structure of a software project.
@@ -88,7 +106,7 @@ models.py: SQLAlchemy database models and engine setup
schemas.py: Pydantic request/response schemas
main.py: FastAPI application with CRUD endpoints
pyproject.toml: project dependencies` },
coder: { name: 'Koodari', avatar: '/avatars/kipina_notext.webp', model: 'qwen-coder', order: 1,
coder: { name: 'Koodari', avatar: '/avatars/kipina_notext.webp', model: 'qwen-coder', order: 2,
temperature: 0.7, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are an expert Python developer. Write complete, production-ready code.
@@ -109,7 +127,7 @@ NEVER:
- Forget to import from other project files
- Use requirements.txt or Poetry — always use pyproject.toml with [project] format (PEP 621)
- Use pip install — use uv (e.g. uv run uvicorn main:app --reload)` },
data: { name: 'Data', avatar: '/avatars/pesukarhu_notext.webp', model: 'qwen-coder', order: 2,
data: { name: 'Data', avatar: '/avatars/pesukarhu_notext.webp', model: 'qwen-coder', order: 3,
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are a database architect specializing in SQLAlchemy and relational databases.
@@ -126,7 +144,7 @@ ALWAYS INCLUDE:
- from sqlalchemy.ext.declarative import declarative_base
- from sqlalchemy.orm import sessionmaker
- DATABASE_URL, engine, SessionLocal, Base` },
qa: { name: 'QA', avatar: '/avatars/susi_notext.webp', model: 'qwen-coder', order: 3,
qa: { name: 'QA', avatar: '/avatars/susi_notext.webp', model: 'qwen-coder', order: 4,
temperature: 0.4, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are a QA engineer writing automated tests.
@@ -143,7 +161,7 @@ TEST STRUCTURE:
5. test_delete: DELETE → 204, verify GET returns 404 after
ALWAYS: from fastapi.testclient import TestClient` },
tester: { name: 'DevOps', avatar: '/avatars/laiskiainen_notext.webp', model: 'qwen-coder', order: 4,
tester: { name: 'DevOps', avatar: '/avatars/laiskiainen_notext.webp', model: 'qwen-coder', order: 5,
temperature: 0.3, topK: 40, repeatPenalty: 1.1, maxTokens: 512,
prompt: `You are a strict code reviewer and static analysis expert. Analyze the code line by line.
@@ -162,7 +180,7 @@ RESPOND:
- If all checks pass: "LGTM"
- If issues found: list each as "ISSUE: filename.py: description"
- Be specific and actionable, not vague` },
observer: { name: 'Tarkkailija', avatar: '/avatars/aikuinen_susi.webp', model: 'qwen-coder', order: 5,
observer: { name: 'Tarkkailija', avatar: '/avatars/aikuinen_susi.webp', model: 'qwen-coder', order: 6,
temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are an independent technical observer and risk analyst.
@@ -178,7 +196,7 @@ OUTPUT FORMAT:
- End with overall assessment: "SHIP IT" or "NEEDS WORK: reason"` },
};
// Versio: kasvata kun oletuspromptit muuttuvat
const AGENTS_VERSION = 2;
const AGENTS_VERSION = 3;
let agents;
const savedVersion = parseInt(localStorage.getItem('kpn-agents-version') || '0');
if (savedVersion < AGENTS_VERSION && localStorage.getItem('kpn-agents')) {
@@ -486,7 +504,7 @@ OUTPUT FORMAT:
// === kpnRun: lähettää promptin mallille ===
const activeStreams = {};
async function kpnRun(model, prompt, silent) {
async function kpnRun(model, prompt, silent, agentOpts) {
const taskId = crypto.randomUUID();
const statusDiv = document.createElement('div');
statusDiv.className = 'terminal-line';
@@ -511,10 +529,24 @@ OUTPUT FORMAT:
statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:var(--accent)">${model}</span> käsittelee...</span>`;
// Rakennetaan pyyntö: agentin asetukset tai globaalit oletukset
const opts = agentOpts || {};
const payload = {
model,
prompt,
task_id: taskId,
system_prompt: opts.systemPrompt || settings.systemPrompt || undefined,
temperature: opts.temperature ?? settings.temperature ?? undefined,
top_k: opts.topK ?? settings.topK ?? undefined,
max_tokens: opts.maxTokens ?? settings.maxTokens ?? undefined,
repeat_penalty: opts.repeatPenalty ?? settings.repeatPenalty ?? undefined,
stop: settings.stopSequences ? settings.stopSequences.split('\\n').filter(Boolean) : undefined,
};
const res = await fetch('/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, task_id: taskId }),
body: JSON.stringify(payload),
});
if (res.status === 503 && !wasmNodeStarted) {
@@ -736,7 +768,8 @@ OUTPUT FORMAT:
}
async function kpnProject(task) {
const cdr = agents.coder || Object.values(agents)[1];
const cli = agents.client || Object.values(agents)[0];
const cdr = agents.coder || Object.values(agents)[2];
// Etsitään sopivin mallipohja
const template = Object.values(templates)[0]; // Toistaiseksi vain FastAPI CRUD
@@ -746,6 +779,15 @@ OUTPUT FORMAT:
}
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ ${esc(template.name)} — ${esc(task)} ━━━</span>`);
// Asiakas: jalostaa vaatimukset
termLog(`\n<span style="color:#f0883e;font-weight:bold">[0] ${esc(cli.name)}</span> — vaatimusmäärittely`);
highlightAgent('client');
explainStep('Vaatimusmäärittely', `${cli.name} muotoilee idean selkeiksi vaatimuksiksi: ominaisuudet, datamallit, rajapinnat.`);
const brief = await kpnRun(cli.model, `${task}`, false, cli);
if (!brief) { termLog(' ✗ Vaatimusmäärittely epäonnistui', '#f85149'); return; }
termLog(` <span style="color:#8b949e">Vaatimukset valmiit → Manageri</span>`);
explainStep('Mallipohja', `Käytetään "${template.name}" -mallipohjaa jossa ${template.order.length} tiedostoa: ${template.order.join(', ')}. Jokainen tiedosto generoidaan järjestyksessä, ja aiemmat tiedostot annetaan kontekstina seuraavalle.`);
const files = {};
@@ -758,7 +800,7 @@ OUTPUT FORMAT:
const step = i + 1;
// Valitaan oikea agentti tiedostotyypin mukaan
const isDbFile = fileName === 'models.py' || fileName === 'database.py';
const dataAgent = agents.data || Object.values(agents)[2];
const dataAgent = agents.data || Object.values(agents)[3];
const fileAgent = isDbFile && dataAgent ? dataAgent : cdr;
const fileAgentKey = isDbFile && dataAgent ? 'data' : 'coder';
@@ -787,12 +829,15 @@ OUTPUT FORMAT:
}
}
// Asiakkaan vaatimusmäärittely
prompt += `PROJECT REQUIREMENTS (from product owner):\n${brief}\n\n`;
// Tehtävä
prompt += `NOW write "${fileName}" for THIS project: ${task}\n`;
prompt += fileDef.instructions + '\n';
prompt += 'Adapt the example to match the project description. Import from already written files. Write ONLY the code, no explanations.';
const code = await kpnRun(fileAgent.model, prompt);
const code = await kpnRun(fileAgent.model, prompt, false, fileAgent);
if (!code) {
termLog(` ✗ Keskeytyi (${fileName})`, '#f85149');
return;
@@ -804,7 +849,7 @@ OUTPUT FORMAT:
let stepN = template.order.length + 1;
// Review-korjausluuppi: max 2 kierrosta
const tst = agents.tester || Object.values(agents)[4];
const tst = agents.tester || Object.values(agents)[5];
const MAX_REVIEW_ROUNDS = 3;
for (let round = 0; round < MAX_REVIEW_ROUNDS; round++) {
@@ -817,7 +862,7 @@ OUTPUT FORMAT:
else explainStep('Uudelleentarkistus', `${tst.name} tarkistaa korjaukset.`);
const reviewPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') + `Review this project:\n\n${currentCode}`;
const review = await kpnRun(tst.model, reviewPrompt);
const review = await kpnRun(tst.model, reviewPrompt, false, tst);
stepN++;
// LGTM → ei korjauksia tarvita
@@ -832,7 +877,7 @@ OUTPUT FORMAT:
explainStep('Korjaus', `${tst.name} löysi ongelmia. ${cdr.name} saa palautteen ja korjaa.`);
const fixPrompt = `${cdr.prompt ? cdr.prompt+'\n\n' : ''}Fix these issues:\n${review}\n\nCurrent code:\n${currentCode}\n\nWrite ALL corrected files. Start each file with: --- filename.py ---`;
const fixedCode = await kpnRun(cdr.model, fixPrompt);
const fixedCode = await kpnRun(cdr.model, fixPrompt, false, cdr);
// Parsitaan korjatut tiedostot takaisin files-objektiin
if (fixedCode) {
@@ -852,13 +897,13 @@ OUTPUT FORMAT:
const updatedCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
// QA: testit (saa korjatut tiedostot)
const qaAgent = agents.qa || Object.values(agents)[3];
const qaAgent = agents.qa || Object.values(agents)[4];
if (qaAgent) {
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — testit`);
highlightAgent('qa');
explainStep('Testit', `${qaAgent.name} kirjoittaa pytest-testit korjatulle koodille.`);
const qaPrompt = (qaAgent.prompt ? qaAgent.prompt+'\n\n' : '') + `Write pytest tests for this project:\n\n${updatedCode}\n\nWrite a complete test_main.py file with TestClient.`;
const tests = await kpnRun(qaAgent.model, qaPrompt);
const tests = await kpnRun(qaAgent.model, qaPrompt, false, qaAgent);
if (tests) files['test_main.py'] = tests;
stepN++;
}
@@ -878,12 +923,12 @@ OUTPUT FORMAT:
`- Expose port 8000\n` +
`- CMD: uv run uvicorn main:app --host 0.0.0.0 --port 8000\n` +
`\nWrite ONLY the Dockerfile, no explanations.`;
const dockerfile = await kpnRun(tst.model, dockerPrompt);
const dockerfile = await kpnRun(tst.model, dockerPrompt, false, tst);
if (dockerfile) files['Dockerfile'] = dockerfile;
stepN++;
// Tarkkailija: yhteenveto + raportti + arvosana
const obs = agents.observer || Object.values(agents)[5];
const obs = agents.observer || Object.values(agents)[6];
if (obs) {
termLog(`\n<span style="color:#8b949e;font-weight:bold">[${stepN}] ${esc(obs.name)}</span> — projektin yhteenveto`);
highlightAgent('observer');
@@ -913,7 +958,7 @@ OUTPUT FORMAT:
`## Architecture\nDescribe the project structure and design decisions.\n\n` +
`## Risk Assessment\n| Severity | Issue |\n|----------|-------|\n| ... | ... |\n\n` +
`Project code:\n${finalCode}`;
const readme = await kpnRun(obs.model, obsPrompt);
const readme = await kpnRun(obs.model, obsPrompt, false, obs);
if (readme) {
files['README.md'] = readme;
// Tallennetaan raportti globaalisti jotta tarkkailija-klikkaus avaa sen
@@ -944,15 +989,24 @@ OUTPUT FORMAT:
}
async function kpnPipelineSimple(task) {
const cli = agents.client || Object.values(agents)[0];
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ Pipeline ━━━</span>`);
termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span>`);
const plan = await kpnRun('qwen-coder', `Analyse briefly, write a spec:\n${task}`);
termLog(`\n<span style="color:#f0883e;font-weight:bold">[1/4] ${esc(cli.name)}</span> — vaatimukset`);
highlightAgent('client');
const brief = await kpnRun(cli.model, `${task}`, false, cli);
if (!brief) return;
termLog(`\n<span style="color:#d29922;font-weight:bold">[2/4] Manageri</span>`);
highlightAgent('manager');
const plan = await kpnRun('qwen-coder', `Requirements:\n${brief}\n\nAnalyse briefly, write a spec:\n${task}`);
if (!plan) return;
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span>`);
termLog(`\n<span style="color:#3fb950;font-weight:bold">[3/4] Koodari</span>`);
highlightAgent('coder');
const code = await kpnRun('qwen-coder', `${plan}\n\nWrite the code.`);
if (!code) return;
termLog(`\n<span style="color:var(--accent);font-weight:bold">[3/3] Testaaja</span>`);
termLog(`\n<span style="color:var(--accent);font-weight:bold">[4/4] Testaaja</span>`);
highlightAgent('tester');
await kpnRun('qwen-coder', `Review briefly:\n${code}`);
highlightAgent(null);
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis ━━━</span>`);
}

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

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

View File

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

View File

@@ -25,7 +25,7 @@ const ALLOWED_ORIGINS: &[&str] = &[
];
// Sallitut viestityyypit clientilta
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "llm_error", "download_progress", "user_text", "single_tokenize_done"];
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "llm_error", "download_progress", "user_text", "single_tokenize_done", "status_update"];
struct AppState {
next_node_id: Mutex<u64>,
@@ -40,9 +40,12 @@ struct AppState {
node_ips: Mutex<HashMap<u64, IpAddr>>,
node_tasks: Mutex<HashMap<u64, String>>, // node_id → selected_task
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ä
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
db: db::NodeDb,
}
@@ -80,6 +83,8 @@ tr:hover td { background:#1c2333; }
.table-wrap { overflow-x:auto; max-height:70vh; overflow-y:auto; }
.online { color:var(--green); }
.offline { color:#8b949e; }
.pause-btn { background:var(--panel); border:1px solid var(--border); color:var(--text); padding:4px 8px; border-radius:4px; cursor:pointer; font-size:12px; }
.pause-btn:hover { border-color:var(--yellow); }
</style>
</head>
<body>
@@ -91,6 +96,7 @@ tr:hover td { background:#1c2333; }
<div class="tabs">
<div class="tab active" onclick="showTab('sessions')">Sessiot</div>
<div class="tab" onclick="showTab('pairs')">Tokenisointiparit</div>
<div class="tab" onclick="showTab('hardware')">Laitteisto & Mallit</div>
</div>
<div id="sessions" class="panel active">
@@ -99,12 +105,12 @@ tr:hover td { background:#1c2333; }
<colgroup>
<col style="width:35px"><col style="width:85px"><col style="width:95px"><col style="width:65px"><col style="width:110px"><col style="width:80px">
<col style="width:65px"><col style="width:40px"><col style="width:70px"><col style="width:90px"><col style="width:60px">
<col style="width:65px"><col style="width:40px"><col style="width:130px"><col style="width:60px">
<col style="width:65px"><col style="width:40px"><col style="width:130px"><col style="width:60px"><col style="width:80px">
</colgroup>
<thead><tr>
<th>ID</th><th>Tila</th><th>Tehtävä</th><th>Tyyppi</th><th>IP</th><th>Alusta</th>
<th>OS</th><th>CPU</th><th>RAM</th><th>GPU</th><th>VRAM</th>
<th>WebGPU</th><th>Teht.</th><th>Yhdistetty</th><th>Kesto</th>
<th>WebGPU</th><th>Teht.</th><th>Yhdistetty</th><th>Kesto</th><th>Toiminnot</th>
</tr></thead><tbody id="sessions-body"></tbody></table>
</div>
</div>
@@ -118,6 +124,19 @@ tr:hover td { background:#1c2333; }
</div>
</div>
<div id="hardware" class="panel">
<div class="stats-grid" id="hardware-stats"></div>
<h2 style="margin-top: 10px; margin-bottom: 10px; color: var(--accent); font-size: 16px;">Käytettävissä olevat paikalliset kielimallit</h2>
<div class="table-wrap">
<table>
<thead><tr>
<th>Nimi</th><th>Koko</th><th>Parametrit</th>
</tr></thead>
<tbody id="models-body"></tbody>
</table>
</div>
</div>
<script>
function showTab(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
@@ -149,12 +168,16 @@ function duration(start, end) {
}
async function load() {
const [statsRes, sessionsRes, pairsRes] = await Promise.all([
fetch('/api/stats'), fetch('/api/sessions'), fetch('/api/pairs')
const [statsRes, sessionsRes, pairsRes, hwRes, modelsRes] = await Promise.all([
fetch('/api/stats'), fetch('/api/sessions'), fetch('/api/pairs'),
fetch('/api/v1/hardware').catch(() => ({json: async()=>({gpu_name:'', vram_mb:0, ram_mb:0})})),
fetch('/api/v1/ollama/tags').catch(() => ({json: async()=>({models:[]})}))
]);
const stats = await statsRes.json();
const sessions = await sessionsRes.json();
const pairs = await pairsRes.json();
const hw = await hwRes.json().catch(() => ({gpu_name:'', vram_mb:0, ram_mb:0}));
const modelsData = await modelsRes.json().catch(() => ({models:[]}));
// Versio
if (stats.version) document.getElementById('admin-version').textContent = 'v' + stats.version;
@@ -190,9 +213,17 @@ async function load() {
document.getElementById('sessions-body').innerHTML = sessions.map(s => {
const online = !s.disconnected_at;
const isViewer = s.selected_task === 'viewer';
const status = online
? (isViewer ? '<span style="color:#d29922">CONNECTED</span>' : '<span class="online">ACTIVE</span>')
: '<span class="offline">offline</span>';
let status;
if (!online) {
status = '<span class="offline">offline</span>';
} else if (isViewer) {
status = '<span style="color:#d29922">CONNECTED</span>';
} else if (s.is_paused) {
status = '<span style="color:#8b949e">PAUSED</span>';
} else {
status = '<span class="online">ACTIVE</span>';
}
const typeBadge = s.node_type === 'native' ? badge('native','blue') : badge('browser','yellow');
const taskColor = isViewer ? 'yellow' : s.selected_task === 'tokenize' ? 'green' : 'blue';
const taskBadge = badge(taskNames[s.selected_task] || s.selected_task || '?', taskColor);
@@ -205,11 +236,16 @@ async function load() {
const os = s.os || '-';
const time = s.connected_at ? new Date(s.connected_at).toLocaleString('fi-FI') : '';
const dur = duration(s.connected_at, s.disconnected_at);
const actionBtn = online && !isViewer
? `<button class="pause-btn" onclick="togglePause(${s.node_id}, ${s.is_paused})">${s.is_paused ? '▶ Työhön' : '⏸ Tauolle'}</button>`
: '';
return `<tr>
<td>${s.node_id}</td><td>${status}</td><td>${taskBadge}</td><td>${typeBadge}</td><td>${s.ip}</td>
<td>${plat}</td><td>${os}</td><td>${cores}</td><td>${ram}</td>
<td>${gpu}</td><td>${vram}</td><td>${gpuBadge}</td>
<td>${s.tasks_completed}</td><td>${time}</td><td>${dur}</td>
<td>${actionBtn}</td>
</tr>`;
}).join('');
@@ -229,6 +265,35 @@ async function load() {
<td>${p.duration_ms||0}ms</td>
</tr>`;
}).join('');
// Hardware
document.getElementById('hardware-stats').innerHTML = [
{v: hw.gpu_name || '-', l: 'Paikallinen GPU tila'},
{v: hw.vram_mb ? hw.vram_mb + ' MB' : '-', l: 'GPU Muisti (VRAM)'},
{v: hw.ram_mb ? hw.ram_mb + ' MB' : '-', l: 'RAM'},
].map(s => `<div class="stat-card"><div class="val">${s.v}</div><div class="label">${s.l}</div></div>`).join('');
// Models
document.getElementById('models-body').innerHTML = (modelsData.models || []).map(m => {
const sizeGb = (m.size / (1024*1024*1024)).toFixed(2) + ' GB';
const params = m.details?.parameter_size || '-';
return `<tr>
<td><strong>${m.name}</strong></td>
<td>${sizeGb}</td>
<td>${params}</td>
</tr>`;
}).join('');
}
async function togglePause(nodeId, isPaused) {
try {
await fetch('/api/v1/control/' + nodeId, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: isPaused ? 'resume' : 'pause' })
});
load(); // virkistetään
} catch(e) { console.error(e); }
}
load();
@@ -262,9 +327,12 @@ async fn main() {
node_ips: Mutex::new(HashMap::new()),
node_tasks: Mutex::new(HashMap::new()),
node_types: Mutex::new(HashMap::new()),
node_paused: Mutex::new(std::collections::HashSet::new()),
node_busy: Mutex::new(std::collections::HashSet::new()),
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()),
db: db::NodeDb::new(&std::env::var("DATABASE_PATH").unwrap_or_else(|_| "nodes.db".to_string())),
});
@@ -381,6 +449,7 @@ async fn main() {
.route("/api/pairs", get(api_pairs))
.route("/api/stats", get(api_stats))
.route("/api/v1/chat/completions", axum::routing::post(api_chat_completions))
.route("/api/v1/control/:id", axum::routing::post(api_control_node))
.route("/api/v1/model", axum::routing::post(api_change_model))
.route("/api/v1/hardware", get(api_hardware))
.route("/api/v1/ollama/tags", get(api_ollama_tags))
@@ -400,6 +469,26 @@ async fn main() {
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}
async fn api_control_node(
headers: axum::http::HeaderMap,
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<u64>,
axum::Json(payload): axum::Json<serde_json::Value>,
) -> axum::response::Response {
if !check_admin_auth(&headers) { return admin_unauthorized(); }
let action = payload.get("action").and_then(|v| v.as_str()).unwrap_or("");
if action == "pause" || action == "resume" {
let msg = serde_json::json!({ "type": "control", "action": action });
let channels = state.node_channels.read().await;
if let Some(tx) = channels.get(&id) {
let _ = tx.send(msg.to_string());
tracing::info!("Lähetetty control: {} solmulle {}", action, id);
return axum::Json(serde_json::json!({"status": "ok"})).into_response();
}
}
(axum::http::StatusCode::BAD_REQUEST, "Invalid action or node offline").into_response()
}
async fn api_sessions(
headers: axum::http::HeaderMap,
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
@@ -563,6 +652,17 @@ async fn broadcast_stats(state: &Arc<AppState>) {
"tasks": completed
});
let _ = state.stats_tx.send(stats_msg.to_string());
// Uutta: Laitetaan sama tieto myös kaikille yhdistyneille solmuille (viesti Hubilta Solmuille)
let node_status = serde_json::json!({
"type": "network_status",
"active_nodes": total_nodes,
"tasks": completed
});
let msg_str = node_status.to_string();
for tx in state.node_channels.read().await.values() {
let _ = tx.send(msg_str.clone());
}
}
/// Validoi client-viesti: pakollinen "type"-kenttä, sallittu tyyppi, validi JSON
@@ -730,6 +830,9 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
}
state.node_tasks.lock().unwrap().insert(node_id, selected_task);
state.node_types.lock().unwrap().insert(node_id, node_type.to_string());
// Uudelleen-kirjautuessa nollataan tauko
state.node_paused.lock().unwrap().remove(&node_id);
state.db.update_session_status(node_id, false);
if node_type == "native" {
let sys = json.get("system");
@@ -743,6 +846,12 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
node_id, ip, hostname, os, cores, ram, allocated
);
// Tallennetaan välitetyt mallit muistiin
if let Some(models) = json.get("models") {
let mut nm = state.node_models.write().await;
nm.insert(node_id, models.clone());
}
if let Some(gpus) = json.get("gpus").and_then(|v| v.as_array()) {
for gpu in gpus {
tracing::info!(
@@ -780,6 +889,18 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
});
let _ = state.stats_tx.send(join_msg.to_string());
} else if msg_type == "status_update" {
let status = json.get("status").and_then(|v| v.as_str()).unwrap_or("active");
if status == "paused" {
state.node_paused.lock().unwrap().insert(node_id);
state.db.update_session_status(node_id, true);
tracing::info!("Solmu {} ({}) asettui tauolle.", node_id, ip);
} else {
state.node_paused.lock().unwrap().remove(&node_id);
state.db.update_session_status(node_id, false);
tracing::info!("Solmu {} ({}) on taas aktiivinen.", node_id, ip);
}
broadcast_stats(&state).await;
} else if msg_type == "result" {
tracing::info!("Solmu {} sai tuloksen: {}", node_id, text);
{
@@ -875,11 +996,18 @@ 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);
let valid_task = if let Some(tid) = json.get("task_id").and_then(|v| v.as_str()) {
state.pending_task_ids.lock().unwrap().remove(tid)
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
let valid_task = if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str())
} else {
false
};
// Jos API-pyyntö odottaa tätä vastausta, reititetään suoraan oneshot-kanavaan
let api_sender = task_id.as_ref().and_then(|tid| {
state.pending_responses.lock().unwrap().remove(tid)
});
{
let mut json = json;
if let Some(obj) = json.as_object_mut() {
@@ -899,6 +1027,12 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
state.db.increment_tasks(node_id);
obj.insert("node_id".to_string(), serde_json::json!(node_id));
}
if let Some(sender) = api_sender {
// API-pyyntö: reititetään vastaus suoraan odottajalle
let _ = sender.send(json.clone());
}
// UI-broadcast jatkuu normaalisti
let _ = state.stats_tx.send(json.to_string());
let active_incentives = state.feature_flags.read().await.get("Insentiivit").copied().unwrap_or(false);
@@ -908,7 +1042,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
{
let mut task_count = state.total_tasks.lock().unwrap();
*task_count += 1;
if active_incentives && valid_task {
let mut tokens = state.nodes_tokens.lock().unwrap();
let balance = tokens.entry(node_id).or_insert(0);
@@ -916,7 +1050,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
current_balance = *balance;
}
}
if active_incentives && ui_sync {
if let Some(tx) = state.node_channels.read().await.get(&node_id) {
let msg = serde_json::json!({
@@ -926,45 +1060,50 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
let _ = tx.send(msg.to_string());
}
}
broadcast_stats(&state).await;
}
} else if msg_type == "llm_error" {
state.node_busy.lock().unwrap().remove(&node_id);
if let Some(tid) = json.get("task_id").and_then(|v| v.as_str()) {
state.pending_task_ids.lock().unwrap().remove(tid);
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str());
}
// Jos API-pyyntö odottaa, reititetään virhe oneshot-kanavaan
let api_sender = task_id.as_ref().and_then(|tid| {
state.pending_responses.lock().unwrap().remove(tid)
});
{
let mut json = json;
if let Some(obj) = json.as_object_mut() {
obj.insert("node_id".to_string(), serde_json::json!(node_id));
}
if let Some(sender) = api_sender {
let _ = sender.send(json.clone());
}
let _ = state.stats_tx.send(json.to_string());
}
} else if msg_type == "user_text" {
// Käyttäjän lähettämä teksti — broadcastataan pair_taskina ja llm_promptina
// Käyttäjän lähettämä teksti — kohdennettu reititys lähettäjäsolmulle
let text = json.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string();
let task_type = json.get("task_type").and_then(|v| v.as_str()).unwrap_or("tokenize");
if !text.is_empty() {
let preview: String = text.chars().take(80).collect();
tracing::info!("Solmu {} lähetti oman tekstin ({}): \"{}\"", node_id, task_type, preview);
match task_type {
"tokenize" => {
let msg = serde_json::json!({
"type": "single_tokenize",
"text": text,
});
let _ = state.stats_tx.send(msg.to_string());
}
_ => {
// LLM-prompti: lähetetään VAIN valitulle mallille, ei kaikille (välttää turhaa ruuhkaa ja busy-tiloja)
let prompt = serde_json::json!({
"type": "llm_prompt",
"prompt": text,
"model": task_type,
});
let _ = state.stats_tx.send(prompt.to_string());
}
let msg = match task_type {
"tokenize" => serde_json::json!({
"type": "single_tokenize",
"text": text,
}),
_ => serde_json::json!({
"type": "llm_prompt",
"prompt": text,
"model": task_type,
}),
};
// Lähetetään takaisin lähettäjäsolmulle (käyttäjä haluaa oman tekstinsä tuloksen)
if let Some(tx) = state.node_channels.read().await.get(&node_id) {
let _ = tx.send(msg.to_string());
}
}
}
@@ -989,6 +1128,8 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
vram.remove(&node_id);
}
state.node_types.lock().unwrap().remove(&node_id);
state.node_paused.lock().unwrap().remove(&node_id);
state.node_models.write().await.remove(&node_id);
tracing::info!("Solmu {} ({}) poistui verkosta.", node_id, ip);
broadcast_stats(&state).await;
sender_task.abort();
@@ -1000,6 +1141,16 @@ struct ChatCompletionRequest {
task_id: String,
#[serde(default)]
max_tokens: Option<u64>,
#[serde(default)]
system_prompt: Option<String>,
#[serde(default)]
temperature: Option<f64>,
#[serde(default)]
top_k: Option<u64>,
#[serde(default)]
repeat_penalty: Option<f64>,
#[serde(default)]
stop: Option<Vec<String>>,
}
#[derive(serde::Serialize)]
@@ -1009,7 +1160,16 @@ struct ChatCompletionResponse {
tokens_generated: u64,
}
async fn api_ollama_tags() -> axum::response::Response {
async fn api_ollama_tags(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
) -> axum::response::Response {
// Haetaan natiivisolmun tila muistista — priorisoidaan aito verkko-solmu
let node_models = state.node_models.read().await;
if let Some((_, models_json)) = node_models.iter().next() {
return axum::Json(models_json.clone()).into_response();
}
// Fallback: Haetaan lokaalista infra-Ollamasta ohjaimesta käsin (esim dev ympäristö)
let ollama_url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
match reqwest::get(format!("{}/api/tags", ollama_url)).await {
Ok(resp) => {
@@ -1033,11 +1193,10 @@ async fn api_hardware(
});
let (mut vram_mb, mut gpu_name, ram_mb) = if let Some(s) = native {
let gpus = s.get("gpus").and_then(|v| v.as_array());
let gpu = gpus.and_then(|g| g.first());
let vram = gpu.and_then(|g| g.get("vram_total_mb")).and_then(|v| v.as_u64()).unwrap_or(0);
let name = gpu.and_then(|g| g.get("name")).and_then(|v| v.as_str()).unwrap_or("").to_string();
let ram = s.get("system").and_then(|v| v.get("ram_total_mb")).and_then(|v| v.as_u64()).unwrap_or(0);
// Tieto on tietokannassa litteänä
let vram = s.get("vram_total_mb").and_then(|v| v.as_u64()).unwrap_or(0);
let name = s.get("gpu_name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let ram = s.get("ram_mb").and_then(|v| v.as_u64()).unwrap_or(0);
(vram, name, ram)
} else {
(0, String::new(), 0)
@@ -1106,7 +1265,9 @@ async fn api_chat_completions(
let tasks = state.node_tasks.lock().unwrap();
let _busy = state.node_busy.lock().unwrap();
let node_types = state.node_types.lock().unwrap();
let matching: Vec<u64> = tasks.iter().filter(|(_, task)| {
let paused = state.node_paused.lock().unwrap();
let matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
if paused.contains(k) { return false; } // Ei sallita tauotettuja
// Eksakti match tai qwen-perheen yhteensopivuus (selain: qwen-coder-05b, natiivi: qwen2.5-coder:7b)
let req_model = payload.model.to_lowercase();
let node_task = task.to_lowercase();
@@ -1157,12 +1318,17 @@ async fn api_chat_completions(
"model": payload.model,
"task_id": payload.task_id,
});
if let Some(mt) = payload.max_tokens {
msg.as_object_mut().unwrap().insert("max_tokens".to_string(), serde_json::json!(mt));
}
let obj = msg.as_object_mut().unwrap();
if let Some(mt) = payload.max_tokens { obj.insert("max_tokens".to_string(), serde_json::json!(mt)); }
if let Some(ref sp) = payload.system_prompt { obj.insert("system_prompt".to_string(), serde_json::json!(sp)); }
if let Some(t) = payload.temperature { obj.insert("temperature".to_string(), serde_json::json!(t)); }
if let Some(k) = payload.top_k { obj.insert("top_k".to_string(), serde_json::json!(k)); }
if let Some(rp) = payload.repeat_penalty { obj.insert("repeat_penalty".to_string(), serde_json::json!(rp)); }
if let Some(ref s) = payload.stop { obj.insert("stop".to_string(), serde_json::json!(s)); }
// Odotuskanava valmiiksi (solmu palauttaa tuloksen stats_tx kautta)
let mut rx = state.stats_tx.subscribe();
// Oneshot-kanava: solmu palauttaa tuloksen suoraan tälle pyynnölle
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel::<serde_json::Value>();
state.pending_responses.lock().unwrap().insert(payload.task_id.clone(), resp_tx);
// Kohdennettu reititys: lähetetään AI-tehtävä suoraan VAIN valitulle solmulle
{
@@ -1171,48 +1337,34 @@ async fn api_chat_completions(
let _ = tx.send(msg.to_string());
tracing::info!("Reititettiin API-pyyntö solmulle {} (Malli: {})", target_node_id, payload.model);
} else {
state.pending_responses.lock().unwrap().remove(&payload.task_id);
return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Verkkovirhe: solmun yhteys katkesi reitityksen aikana").into_response();
}
}
let timeout = tokio::time::timeout(std::time::Duration::from_secs(600), async move {
loop {
let msg_str = match rx.recv().await {
Ok(msg) => msg,
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!("API-kanava lagged {} viestiä", n);
continue;
}
Err(_) => return Ok(None), // Kanava suljettu
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg_str) {
if v["type"].as_str() == Some("llm_done") {
if let Some(tid) = v["task_id"].as_str() {
if tid == payload.task_id {
return Ok(Some(ChatCompletionResponse {
response: v["response"].as_str().unwrap_or("").to_string(),
model: v["model"].as_str().unwrap_or("").to_string(),
tokens_generated: v["tokens_generated"].as_u64().unwrap_or(0),
}));
}
}
} else if v["type"].as_str() == Some("llm_error") {
if let Some(tid) = v["task_id"].as_str() {
if tid == payload.task_id {
return Err(v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string());
}
}
}
}
}
#[allow(unreachable_code)]
Ok(None)
}).await;
let timeout = tokio::time::timeout(std::time::Duration::from_secs(600), resp_rx).await;
match timeout {
Ok(Ok(Some(res))) => axum::Json(res).into_response(),
Ok(Ok(None)) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response(),
Ok(Err(err)) => (axum::http::StatusCode::CONFLICT, err).into_response(),
Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response(),
Ok(Ok(v)) => {
if v["type"].as_str() == Some("llm_error") {
let err = v["error"].as_str().unwrap_or("Määrittelemätön virhe solmussa").to_string();
(axum::http::StatusCode::CONFLICT, err).into_response()
} else {
axum::Json(ChatCompletionResponse {
response: v["response"].as_str().unwrap_or("").to_string(),
model: v["model"].as_str().unwrap_or("").to_string(),
tokens_generated: v["tokens_generated"].as_u64().unwrap_or(0),
}).into_response()
}
}
Ok(Err(_)) => {
// Oneshot-kanava sulkeutui (solmu katosi)
state.pending_responses.lock().unwrap().remove(&payload.task_id);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response()
}
Err(_) => {
state.pending_responses.lock().unwrap().remove(&payload.task_id);
(axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response()
}
}
}

View File

@@ -19,3 +19,8 @@ wgpu = { version = "24", optional = true }
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"

View File

@@ -1,6 +1,15 @@
use std::time::Instant;
use std::cell::RefCell;
pub struct GenerateOptions {
pub max_tokens: usize,
pub system_prompt: Option<String>,
pub temperature: Option<f64>,
pub top_k: Option<u64>,
pub repeat_penalty: Option<f64>,
pub stop: Option<Vec<String>>,
}
pub struct LlmEngine {
ollama_url: String,
model: RefCell<String>,
@@ -9,8 +18,6 @@ pub struct LlmEngine {
impl LlmEngine {
pub async fn load() -> Result<Self, String> {
let model = std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "qwen2.5-coder:3b".to_string());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(3))
@@ -48,6 +55,12 @@ impl LlmEngine {
})
};
// Kysytään malli TUI:lla jos ei pakotettu ympäristöstä
let model = match std::env::var("OLLAMA_MODEL") {
Ok(m) if !m.is_empty() => m,
_ => crate::tui::select_model(&ollama_url, &client).await?
};
tracing::info!("Ollama backend: {} | malli: {}", ollama_url, model);
Ok(LlmEngine { ollama_url, model: RefCell::new(model), client })
}
@@ -78,25 +91,48 @@ impl LlmEngine {
}
}
pub async fn generate(&self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> {
// System prompt tulee agentin konfiguraatiosta (frontend lähettää sen osana promptia).
// Tässä ei yliajeta sitä — Ollama saa vain prompt-kentän.
/// Hakee kaikki Ollamaan asennetut mallit
pub async fn fetch_models(&self) -> Result<serde_json::Value, String> {
let resp = self.client.get(format!("{}/api/tags", self.ollama_url))
.send()
.await
.map_err(|e| format!("Ollama tags fetch: {}", e))?;
if resp.status().is_success() {
resp.json().await.map_err(|e| format!("Ollama tags json: {}", e))
} else {
Err(format!("Ollama tags epäonnistui: {}", resp.status()))
}
}
pub async fn generate(&self, prompt: &str, opts: &GenerateOptions) -> Result<GenerateResult, String> {
let model = self.model.borrow().clone();
let default_stop: Vec<String> = vec![
"<|im_end|>".into(), "\n###".into(), "\nExplanation".into(),
"\nNote:".into(), "\nPlease note".into(), "\nThis is".into(),
"\n```\n\n".into(), "\n// Example".into(), "\n# Example".into(),
];
let mut body = serde_json::json!({
"model": model,
"prompt": prompt,
"stream": false,
"options": {
"num_predict": opts.max_tokens,
"temperature": opts.temperature.unwrap_or(0.7),
"top_k": opts.top_k.unwrap_or(40),
"repeat_penalty": opts.repeat_penalty.unwrap_or(1.15),
"stop": opts.stop.as_ref().unwrap_or(&default_stop),
}
});
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))
.json(&serde_json::json!({
"model": model,
"prompt": prompt,
"stream": false,
"options": {
"num_predict": max_tokens,
"temperature": 0.7,
"top_k": 40,
"repeat_penalty": 1.15,
"stop": ["<|im_end|>", "\n###", "\nExplanation", "\nNote:", "\nPlease note", "\nThis is", "\n```\n\n", "\n// Example", "\n# Example"]
}
}))
.json(&body)
.send()
.await
.map_err(|e| format!("Ollama generate: {}", e))?;

View File

@@ -5,6 +5,8 @@ use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
mod inference;
mod tui;
mod tui_dashboard;
/// GPU-tietorakenne — yhtenäinen kaikille valmistajille
struct GpuInfo {
@@ -222,7 +224,7 @@ fn collect_system_info() -> serde_json::Value {
}
/// Koko auth-viesti hubille
fn build_auth_message(allocated_gb: u32) -> String {
fn build_auth_message(allocated_gb: u32, model_name: &str, models_data: Option<serde_json::Value>) -> String {
let sys = collect_system_info();
let gpus = collect_all_gpus();
@@ -239,7 +241,7 @@ fn build_auth_message(allocated_gb: u32) -> String {
"status": "agent_ready",
"node_type": "native",
"allocated_gb": allocated_gb,
"selected_task": "qwen2.5-coder:7b",
"selected_task": model_name,
"system": sys,
});
@@ -251,6 +253,10 @@ fn build_auth_message(allocated_gb: u32) -> String {
msg.as_object_mut().unwrap().insert("gpus".to_string(), json!(gpu_json));
}
if let Some(models) = models_data {
msg.as_object_mut().unwrap().insert("models".to_string(), models);
}
msg.to_string()
}
@@ -263,10 +269,24 @@ fn format_optional<T: std::fmt::Display>(val: Option<T>, suffix: &str) -> String
#[tokio::main]
async fn main() {
let file_appender = tracing_appender::rolling::never(".", "native-node.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_env_filter("native_node=debug")
.with_writer(non_blocking)
.init();
// Hookataan paniikkitilanteet palauttamaan terminaalin raw-moodista
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
tui_dashboard::restore_terminal();
original_hook(panic_info);
}));
let tui_state = std::sync::Arc::new(tokio::sync::RwLock::new(tui_dashboard::DashboardState::new()));
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let hub_url = std::env::var("HUB_URL").unwrap_or_else(|_| "ws://hub:3000/ws".to_string());
let allocated_gb: u32 = std::env::var("ALLOCATED_GB")
.ok()
@@ -282,6 +302,18 @@ async fn main() {
sys["cpu_cores"],
sys["ram_total_mb"]
);
{
let mut st = tui_state.write().await;
st.sys_info = format!("{} | {} | {} ydintä | {} MB RAM",
sys["hostname"].as_str().unwrap_or("?"),
sys["os"].as_str().unwrap_or("?"),
sys["cpu_cores"],
sys["ram_total_mb"]
);
let i = st.sys_info.clone();
st.push_log("System", format!("Järjestelmä: {}", i), None);
}
let gpus = collect_all_gpus();
if gpus.is_empty() {
@@ -321,6 +353,36 @@ async fn main() {
}
};
let active_model = llm.as_ref().map(|e| e.model_name()).unwrap_or_else(|| "unknown".to_string());
tracing::info!("Käytettävä kielimalli konfiguroitu (selected_task): {}", active_model);
{
let mut st = tui_state.write().await;
st.model_name = active_model.clone();
st.push_log("System", format!("Malli valmis: {}", active_model), None);
}
// Käynnistetään graafinen TUI 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);
}
});
// Haetaan paikalliset mallit hubille lähetettäväksi
let mut available_models = None;
if let Some(ref engine) = llm {
match engine.fetch_models().await {
Ok(models) => {
available_models = Some(models);
}
Err(e) => {
tracing::warn!("Mallilistauksen haku epäonnistui: {}", e);
}
}
}
// Yhdistetään hubiin
loop {
match connect_async(&hub_url).await {
@@ -328,80 +390,184 @@ async fn main() {
tracing::info!("Yhdistetty hubiin!");
let (mut write, mut read) = ws_stream.split();
let auth = build_auth_message(allocated_gb);
let auth = build_auth_message(allocated_gb, &active_model, available_models.clone());
if write.send(Message::Text(auth)).await.is_err() {
tracing::error!("Auth-viestin lähetys epäonnistui");
continue;
}
while let Some(Ok(msg)) = read.next().await {
if let Message::Text(text) = msg {
// LLM-promptit
if text.contains("llm_prompt") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
let msg_model = task.get("model").and_then(|v| v.as_str()).unwrap_or("");
if !prompt.is_empty() && (msg_model.starts_with("qwen-coder") || msg_model.starts_with("qwen2.5-coder")) {
if let Some(ref engine) = llm {
let max_tokens = task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(1024) as usize;
let prompt_lines = prompt.lines().count();
let prompt_last: String = prompt.lines().last().unwrap_or("").chars().take(60).collect();
tracing::info!("→ task_id:{} | {}r prompti | \"{}...\"", task_id, prompt_lines, prompt_last);
let model_name = engine.model_name();
match engine.generate(prompt, max_tokens).await {
Ok(result) => {
tracing::info!(
"✓ {} | {} tok | {:.0}ms | {:.1} tok/s",
model_name,
result.tokens_generated,
result.duration_ms,
result.tokens_per_sec,
);
// Lähetetään vain lyhyt prompti-esikatselu (ei koko kontekstia)
let prompt_short: String = prompt.lines().last().unwrap_or("").chars().take(100).collect();
let done = json!({
"type": "llm_done",
"prompt": prompt_short,
"model": format!("{} (Ollama)", model_name),
"response": result.text,
"tokens_generated": result.tokens_generated,
"duration_ms": result.duration_ms,
"tokens_per_sec": (result.tokens_per_sec * 10.0).round() / 10.0,
"load_time_ms": 0,
"task_id": task_id,
});
let _ = write.send(Message::Text(done.to_string())).await;
}
Err(e) => {
tracing::error!("Inferenssivirhe: {}", e);
}
}
loop {
tokio::select! {
cmd = cmd_rx.recv() => {
if let Some(cmd_str) = cmd {
if cmd_str == "pause" {
tracing::info!("Tauotetaan solmun suoritus (Hub ei lähetä tehtäviä)...");
let req = json!({"type": "status_update", "status": "paused"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "PAUSED".to_string();
st.push_log("Network", "Solmu siirretty taukotilaan".to_string(), None);
}
} else if cmd_str == "resume" {
tracing::info!("Jatketaan solmun suoritusta...");
let req = json!({"type": "status_update", "status": "active"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("System", "Suoritus jatkuu...".to_string(), None);
}
}
}
}
// Mallin vaihto lennossa
if text.contains("change_model") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(new_model) = task.get("model").and_then(|v| v.as_str()) {
if let Some(ref engine) = llm {
tracing::info!("Vaihdetaan malli: {}", new_model);
engine.set_model(new_model.to_string());
match engine.ensure_model().await {
Ok(()) => tracing::info!("Malli {} valmis!", new_model),
Err(e) => tracing::error!("Mallin lataus epäonnistui: {}", e),
ws_msg = read.next() => {
match ws_msg {
Some(Ok(Message::Text(text))) => {
// Hubin control-viestit
if text.contains(r#""type":"control""#) {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(action) = task.get("action").and_then(|v| v.as_str()) {
if action == "pause" {
tracing::info!("Hub pakotti solmun tauolle (Pause)");
let req = json!({"type": "status_update", "status": "paused"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "PAUSED".to_string();
st.push_log("Network", "Hub kytki solmun tauolle".to_string(), None);
}
} else if action == "resume" {
tracing::info!("Hub aktivoi solmun suorituksen (Resume)");
let req = json!({"type": "status_update", "status": "active"});
let _ = write.send(Message::Text(req.to_string())).await;
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("Network", "Hub palautti solmun töihin".to_string(), None);
}
}
}
}
}
// Verkon globaali tila
if text.contains(r#""type":"network_status""#) {
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(nodes) = status.get("active_nodes").and_then(|v| v.as_u64()) {
if let Some(tasks) = status.get("tasks").and_then(|v| v.as_u64()) {
let mut st = tui_state.write().await;
st.network_active_nodes = nodes as usize;
st.network_total_tasks = tasks;
}
}
}
}
// LLM-promptit
if text.contains("llm_prompt") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
let msg_model = task.get("model").and_then(|v| v.as_str()).unwrap_or("");
if !prompt.is_empty() && (msg_model.starts_with("qwen-coder") || msg_model.starts_with("qwen2.5-coder") || msg_model.starts_with("phi")) {
if let Some(ref engine) = llm {
let gen_opts = inference::GenerateOptions {
max_tokens: task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(1024) as usize,
system_prompt: task.get("system_prompt").and_then(|v| v.as_str()).map(|s| s.to_string()),
temperature: task.get("temperature").and_then(|v| v.as_f64()),
top_k: task.get("top_k").and_then(|v| v.as_u64()),
repeat_penalty: task.get("repeat_penalty").and_then(|v| v.as_f64()),
stop: task.get("stop").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(|s| s.to_string())).collect()),
};
let prompt_lines = prompt.lines().count();
let prompt_last: String = prompt.lines().last().unwrap_or("").chars().take(60).collect();
tracing::info!("→ task_id:{} | {}r prompti | \"{}...\"", task_id, prompt_lines, prompt_last);
{
let mut st = tui_state.write().await;
st.cur_task_id = Some(task_id.to_string());
st.cur_prompt = Some(format!("{} riviä | \"{}...\"", prompt_lines, prompt_last));
}
let model_name = engine.model_name();
match engine.generate(prompt, &gen_opts).await {
Ok(result) => {
let tokens_sec = (result.tokens_per_sec * 10.0).round() / 10.0;
tracing::info!(
"✓ {} | {} tok | {:.0}ms | {:.1} tok/s",
model_name,
result.tokens_generated,
result.duration_ms,
tokens_sec,
);
{
let mut st = tui_state.write().await;
st.tasks_completed += 1;
st.last_tokens_sec = tokens_sec as f64;
st.cur_task_id = None;
st.cur_prompt = None;
let msg_type = if task_id == "status-check" { "Ping" } else { "Task" };
let msg_text = format!("{} ({} tok)", task_id, result.tokens_generated);
st.push_log(msg_type, msg_text, Some(tokens_sec as f64));
}
let prompt_short: String = prompt.lines().last().unwrap_or("").chars().take(100).collect();
let done = json!({
"type": "llm_done",
"prompt": prompt_short,
"model": format!("{} (Ollama)", model_name),
"response": result.text,
"tokens_generated": result.tokens_generated,
"duration_ms": result.duration_ms,
"tokens_per_sec": tokens_sec,
"load_time_ms": 0,
"task_id": task_id,
});
let _ = write.send(Message::Text(done.to_string())).await;
}
Err(e) => {
tracing::error!("Inferenssivirhe: {}", e);
{
let mut st = tui_state.write().await;
st.cur_task_id = None;
st.cur_prompt = None;
st.push_log("System", format!("Virhe inferenssissä: {}", e), None);
}
}
}
}
}
}
}
// Mallin vaihto lennossa
if text.contains("change_model") {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(new_model) = task.get("model").and_then(|v| v.as_str()) {
if let Some(ref engine) = llm {
tracing::info!("Vaihdetaan malli: {}", new_model);
engine.set_model(new_model.to_string());
match engine.ensure_model().await {
Ok(()) => {
tracing::info!("Malli {} valmis!", new_model);
let mut st = tui_state.write().await;
st.model_name = new_model.to_string();
st.push_log("System", format!("Malli {} ladattu & valmis!", new_model), None);
}
Err(e) => tracing::error!("Mallin lataus epäonnistui: {}", e),
}
}
}
}
}
}
Some(Ok(_)) => {} // Muut viestityypit (binary/ping)
Some(Err(_)) | None => break, // Yhteys poikki
}
}
}
}
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
}
Err(e) => {

View File

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

View File

@@ -0,0 +1,223 @@
use crossterm::{
event::{self, Event, EventStream, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Alignment},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph, Wrap},
Terminal,
};
use std::io;
use tokio::sync::RwLock;
use std::sync::Arc;
use futures_util::StreamExt;
use std::time::Duration;
#[derive(Clone)]
pub struct LogEntry {
pub ty: String,
pub msg: String,
pub speed: Option<f64>,
}
pub struct DashboardState {
pub logs: Vec<LogEntry>,
pub status: String,
pub node_id: Option<u64>,
pub sys_info: String,
pub model_name: String,
pub cur_task_id: Option<String>,
pub cur_prompt: Option<String>,
pub tasks_completed: u32,
pub last_tokens_sec: f64,
pub network_active_nodes: usize,
pub network_total_tasks: u64,
}
impl DashboardState {
pub fn new() -> Self {
Self {
logs: Vec::new(),
status: "ACTIVE".to_string(),
node_id: None,
sys_info: "".to_string(),
model_name: "Yhdistetään...".to_string(),
cur_task_id: None,
cur_prompt: None,
tasks_completed: 0,
last_tokens_sec: 0.0,
network_active_nodes: 1, // oletetaan itsemme
network_total_tasks: 0,
}
}
pub fn push_log(&mut self, ty: &str, msg: String, speed: Option<f64>) {
self.logs.push(LogEntry {
ty: ty.to_string(),
msg,
speed,
});
if self.logs.len() > 100 {
self.logs.remove(0);
}
}
}
pub async fn run_dashboard(
state: Arc<RwLock<DashboardState>>,
cmd_tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<(), io::Error> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let mut reader = EventStream::new();
let mut interval = tokio::time::interval(Duration::from_millis(100));
loop {
tokio::select! {
_ = interval.tick() => {
let st = state.read().await;
terminal.draw(|f| ui(f, &st))?;
}
ev = reader.next() => {
if let Some(Ok(Event::Key(key))) = ev {
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);
}
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());
}
_ => {}
}
}
}
}
}
}
pub fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Body
Constraint::Length(3), // Footer / Status
].as_ref())
.split(f.area());
// --- Header ---
let header_text = match st.node_id {
Some(id) => format!(" Kipinä Agentic Node #{} ", id),
None => " Kipinä Agentic Node (Yhdistää...) ".to_string(),
};
let header = Paragraph::new(header_text)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).style(Style::default().fg(Color::DarkGray)));
f.render_widget(header, chunks[0]);
// --- Body ---
let body_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), // Yläosan info ja tehtävä
Constraint::Min(0), // Lokit / Chat alas
].as_ref())
.split(chunks[1]);
let top_panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40), // Vasen paneeli (Info)
Constraint::Percentage(60), // Oikea paneeli (Tehtävä)
].as_ref())
.split(body_chunks[0]);
// Vasen paneeli: Laitteisto, Malli & Verkosto
let info_text = format!(
"🚀 Malli: {}\n💻 Järjestelmä: {}\n📊 Tehdyt: {} | Nopeus: {} t/s\n🌐 Verkosto: {} solmua | {} tehtävää",
st.model_name, st.sys_info, st.tasks_completed, st.last_tokens_sec, st.network_active_nodes, st.network_total_tasks
);
let left_panel = Paragraph::new(info_text)
.block(Block::default().title(" Laitteisto ja AI ").borders(Borders::ALL))
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: true });
f.render_widget(left_panel, top_panels[0]);
// Oikea paneeli: Käynnissä oleva tehtävä
let task_title = match &st.cur_task_id {
Some(id) => format!(" Työn alla: {} ", id),
None => " Vapaana ".to_string(),
};
let task_content = st.cur_prompt.clone().unwrap_or_else(|| "Odotetaan tehtäviä Hubilta...".to_string());
let task_style = if st.cur_task_id.is_some() {
Style::default().fg(Color::Magenta)
} else {
Style::default().fg(Color::DarkGray)
};
let task_panel = Paragraph::new(task_content)
.wrap(Wrap { trim: true })
.block(Block::default().title(task_title).borders(Borders::ALL).style(task_style));
f.render_widget(task_panel, top_panels[1]);
// Alaosan paneeli: Tapahtumaloki koko leveydeltä
let area_height = body_chunks[1].height.saturating_sub(2) as usize;
let skip_count = if st.logs.len() > area_height { st.logs.len() - area_height } else { 0 };
let visible_logs: Vec<ratatui::text::Line> = st.logs.iter().skip(skip_count).map(|log| {
let ty_color = match log.ty.as_str() {
"System" => Color::Yellow,
"Network" => Color::Blue,
"Task" => Color::Magenta,
"Ping" => Color::DarkGray,
_ => Color::White,
};
let speed_str = if let Some(s) = log.speed {
format!(" | {:.1} tok/s", s)
} else {
"".to_string()
};
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(format!("{: <8}", log.ty), Style::default().fg(ty_color).add_modifier(Modifier::BOLD)),
ratatui::text::Span::raw(" | "),
ratatui::text::Span::styled(log.msg.clone(), Style::default().fg(Color::White)),
ratatui::text::Span::styled(speed_str, Style::default().fg(ty_color)),
])
}).collect();
let logs_panel = Paragraph::new(visible_logs)
.block(Block::default().title(" Tapahtumaloki ").borders(Borders::ALL).style(Style::default().fg(Color::Cyan)));
f.render_widget(logs_panel, body_chunks[1]);
// --- Footer / Status ---
let status_color = if st.status == "ACTIVE" { Color::Green } else { Color::Yellow };
let status_text = format!(" Tila: {} | Komennot: [P] Pause / [R] Työhön / [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]);
}

View File

@@ -368,11 +368,17 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
let _ = ws_clone.borrow().send_with_str(&err_msg.to_string());
}
} else {
// Välitetään parametrit JSON-promptina coderille
let coder_prompt = serde_json::json!({
"prompt": prompt,
"system": task.get("system_prompt").and_then(|v| v.as_str()).unwrap_or(""),
"max_tokens": task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(512),
}).to_string();
let use_3b = current_task == 5;
LLM_BUSY.store(true, Ordering::SeqCst);
let ws_for_async = ws_clone.clone();
wasm_bindgen_futures::spawn_local(async move {
qwen_coder::run_coder_inference(prompt, ws_for_async, use_3b, task_id).await;
qwen_coder::run_coder_inference(coder_prompt, ws_for_async, use_3b, task_id).await;
LLM_BUSY.store(false, Ordering::SeqCst);
});
}

Binary file not shown.

33
network-poc/start-local.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/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!"