28 Commits

Author SHA1 Message Date
30e81875db Reconnect yhdellä rivillä: ei floodata terminaalia
Sama rivi päivittyy laskurilla: '↻ Yhdistetään uudelleen... (3)'
Rivi poistetaan kun yhteys palautuu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:18:13 +03:00
73bcd3143a WebSocket auto-reconnect: yhteys palautuu 3s kuluttua katkoksesta
connectHub() luo uuden WebSocketin ja asettaa onopen/onclose/onmessage.
onclose käynnistää 3s timerin joka kutsuu connectHub() uudelleen.
Terminaaliin tulee '↻ Yhdistetään uudelleen...' -viesti.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:14:52 +03:00
216b95d15c kpn load: laitteiston VRAM/RAM tarkistus, liian isot mallit merkitään
Hub: uusi GET /api/v1/hardware palauttaa natiivisolmun GPU/RAM-tiedot.
Frontend: kpn load hakee laitteistotiedon ja näyttää mallit joihin
laite riittää. Liian isot mallit näkyvät yliviivattuina + varoitus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:08:34 +03:00
34ef19472a kpn load: Ollama-mallin vaihto lennossa (0.5b → 32b)
- Hub: uusi POST /api/v1/model endpoint, broadcastaa change_model
- Native node: kuuntelee change_model, kutsuu Ollaman pull + vaihtaa mallin
- Frontend: kpn load näyttää 5 mallia, numero vaihtaa Ollaman mallin
- Selain-WASM pysyy 0.5B:nä (kpn load 1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:05:57 +03:00
54a5af96c7 Tab-autokorjaus: korjattu ohitettu autocorrect Tab-handlerissa
Tab-painallus meni suoraan dropdown-getCandidatesiin eikä kutsunut
autocorrectiä. Nyt Tab yrittää ensin korjata typon, sitten täydentää.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:00:06 +03:00
842153a7ec uv-paketinhallinta: Dockerfile, README ja pyproject.toml käyttävät uv:tä
Dockerfile kopioi uv:n ghcr.io/astral-sh/uv:latest -imagesta.
README ohjeistaa uv sync + uv run. pyproject.toml pysyy ennallaan
(uv-yhteensopiva formaatti).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:45:08 +03:00
5c25c7f9c1 DevOps Dockerfile-prompti: pip-only, ei poetryä/condaa
Malli generoi poetry.lock-riippuvaisen Dockerfilen. Nyt prompti
kertoo tarkan riippuvuuksien asennustavan (pyproject.toml/requirements.txt/pip)
ja antaa valmiin CMD-rivin. Yksivaiheinen build riittää Pythonille.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:44:03 +03:00
ac698a766e DevOps-agentti: Dockerfile + docker-compose.yml + README pipeline-vaiheina
DevOps generoi nyt kolme tiedostoa:
- Dockerfile (multi-stage build, python:3.12-slim)
- docker-compose.yml (palvelut, volumet, portit)
- README.md (quick start docker compose up)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:41:34 +03:00
f1b57a6c53 Tab korjaa kirjoitusvirheet + fuzzy-match alikomennoille
Tab-painallus yrittää ensin autokorjausta (typo-taulukko + Levenshtein),
sitten normaalia tab-completionia. Myös alikomennot korjautuvat
fuzzy-matchilla (esim. "kpn rnu" → "kpn run").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:37:51 +03:00
b70cdbd24d Terminaalin autokorjaus: knp→kpn, kpn rnu→kpn run jne.
Typo-taulukko yleisimmille kirjoitusvirheille + Levenshtein-etäisyys
tuntemattomille ensimmäisille sanoille (max 2 merkin ero → kpn).
Korjaus näytetään terminaalissa keltaisella.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:35:30 +03:00
01d8b597e1 ZIP CRC-32 checksum lisätty: purkaminen ei enää epäonnistu
Local file header ja central directory entry -tietueista puuttui
CRC-32 kenttä. Lisätty crc32()-funktio ja kirjoitetaan checksum
molempiin ZIP-rakenteisiin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:31:42 +03:00
f2ca4890df Dockerfile: touch main.rs ennen buildia, estää stub-binaryn jäämisen
Cargo ei rekompiloi jos vanha binääri on olemassa.
touch pakottaa uudelleenkäännöksen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:26:33 +03:00
3eb0c4d939 Ollama-integraatio: GPU-inferenssi NVIDIA/AMD/Apple, ei Candle-rajoitteita
- docker-compose: Ollama-container GPU:lla + persistent volume malleille
- native-node: Candle poistettu, kutsuu Ollaman HTTP API:a (async)
- Dockerfile: yksinkertaistettu, ei CUDA SDK:ta (Ollama hoitaa GPU:n)
- Tukee kaikkia malleja: qwen2.5-coder:1.5b/3b/7b/14b/32b
- OLLAMA_MODEL ympäristömuuttujalla vaihdetaan malli
- kpn models näyttää Ollama-mallit nopeustiedoilla

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:22:11 +03:00
d8443792a3 kpn load ja kpn models selkeytetty: selain vs natiivi
kpn load lataa 0.5B selaimeen (ainoa joka toimii WASM:ssa).
kpn models näyttää molemmat vaihtoehdot nopeustiedoilla.
Ei enää harhaanjohtavia numerovalintoja.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:06:12 +03:00
ae379bdda4 Zippi korjattu 2026-04-07 06:00:49 +03:00
ed02e47158 ZIP-lataus korjattu: tiedostot globaaliin muuttujaan data-attribuutin sijaan
JSON data-attribuutissa heittomerkit katkaisivat HTML:n.
Nyt projectFiles[cardId] tallentaa tiedostot muistiin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:00:17 +03:00
959dc532bb native-laskentaan säätöä 2026-04-07 05:20:54 +03:00
1ef7f7c956 max_tokens per vaihe: manageri 200, koodari 512, testaaja 200, QA 512, DevOps 256
Hub ja natiivisolmu tukevat nyt max_tokens-kenttää API-pyynnöissä.
Pipeline-vaiheet käyttävät sopivan kokoisia token-rajoja.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:03:26 +03:00
e6e1f60935 Pipeline: QA kirjoittaa testit + DevOps tekee README:n
Uudet vaiheet koodiarvioinnin jälkeen:
- QA: kirjoittaa test_app.py (pytest, max 3 testiä)
- DevOps: kirjoittaa README.md (asennus, käynnistys, testaus)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:00:04 +03:00
322c98ff59 Pipeline-promptit: rajoitteet kerrottu managerille ja koodarille
Manageri tietää nyt 400 tokenin rajan per tiedosto ja pitää
tiedostomäärän max 3:ssa. Koodari kirjoittaa lyhyttä, fokusoidusti.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:58:51 +03:00
406e2226f0 Native node max_tokens 64→512: koodi ei jää kesken
64 tokenia riitti vain funktion alkuun. 512 mahdollistaa
kokonaisten tiedostojen generoinnin pipeline-vaiheissa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:57:42 +03:00
9d7496157c Native node CPU-moodi: Candle 0.8 RMS-norm ei tue CUDA:a
candle-core 0.8 ei sisällä rms-norm CUDA-kerneliä → inferenssi epäonnistui.
Vaihdettu CPU:ksi joka on silti ~10-20× nopeampi kuin selaimen WASM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:52:50 +03:00
d332b7e910 Hub priorisoi natiivisolmut (GPU) selainsolmujen edelle
Lisätty node_types HashMap joka seuraa solmutyyppiä (native/browser).
API reitittää tehtävät ensin vapaalle natiivisolmulle, sitten selaimelle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:45:20 +03:00
8e55a15d66 bugifiksejä 2026-04-06 21:34:03 +03:00
4e3134d908 CUDA_COMPUTE_CAP=89: bindgen_cuda ei tarvitse nvidia-smi:tä buildissa
candle-kernels build vaatii GPU-arkkitehtuurin tunnistusta.
nvidia-smi ei ole saatavilla Docker build -vaiheessa, joten asetetaan
CUDA_COMPUTE_CAP manuaalisesti (RTX 4090 = sm_89).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:26:03 +03:00
cd45db001a Dockerfile.native-node: lisätty cli/ workspace-jäsen
Cargo workspace vaatii kaikkien jäsenten Cargo.toml:n kopioinnin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:23:48 +03:00
4ad8a8793e Native node CUDA Docker: nvidia/cuda base + GPU runtime
Dockerfile käyttää nvidia/cuda:12.6.3 -imagea jossa CUDA-kirjastot
ovat valmiina. docker-compose lisää runtime: nvidia + NVIDIA_VISIBLE_DEVICES.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:02:43 +03:00
b2694c232e Poistettu 1.5B Q4 -vaihtoehto: GGUF dequantisointi liian hidas WASM:ssa
1.5B Q4_K_M: ~33s/token (0.03 tok/s) — käyttökelvoton
0.5B F32:    ~2.5s/token (0.4 tok/s)  — käyttökelpoinen

kpn load lataa nyt suoraan 0.5B:n ilman valintalistaa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:19:34 +03:00
8 changed files with 504 additions and 336 deletions

View File

@@ -1,7 +1,7 @@
FROM rust:slim AS builder FROM rust:slim AS builder
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
pkg-config libssl-dev g++ \ pkg-config libssl-dev g++ libvulkan-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -9,22 +9,27 @@ COPY Cargo.toml Cargo.lock ./
COPY hub/Cargo.toml hub/Cargo.toml COPY hub/Cargo.toml hub/Cargo.toml
COPY node/Cargo.toml node/Cargo.toml COPY node/Cargo.toml node/Cargo.toml
COPY native-node/Cargo.toml native-node/Cargo.toml COPY native-node/Cargo.toml native-node/Cargo.toml
COPY cli/Cargo.toml cli/Cargo.toml
# Tyhjät src-tiedostot riippuvuuksien esikääntämistä varten # Tyhjät src-tiedostot riippuvuuksien esikääntämistä varten
RUN mkdir -p hub/src node/src native-node/src \ RUN mkdir -p hub/src node/src native-node/src cli/src \
&& echo "fn main(){}" > hub/src/main.rs \ && echo "fn main(){}" > hub/src/main.rs \
&& echo "" > node/src/lib.rs \ && echo "" > node/src/lib.rs \
&& echo "fn main(){}" > native-node/src/main.rs \ && echo "fn main(){}" > native-node/src/main.rs \
&& echo "fn main(){}" > cli/src/main.rs \
&& cargo build --release -p native-node 2>/dev/null || true && cargo build --release -p native-node 2>/dev/null || true
COPY native-node/src native-node/src COPY native-node/src native-node/src
RUN cargo build --release -p native-node # Touch pakottaa rekompilauksen dummy-binaryn yli
RUN touch native-node/src/main.rs && cargo build --release -p native-node
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y ca-certificates libvulkan1 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/native-node /usr/local/bin/native-node COPY --from=builder /app/target/release/native-node /usr/local/bin/native-node
ENV HUB_URL=ws://hub:3000/ws ENV HUB_URL=ws://agentic-poc:3000/ws
ENV OLLAMA_URL=http://ollama:11434
ENV OLLAMA_MODEL=qwen2.5-coder:7b
ENV ALLOCATED_GB=4 ENV ALLOCATED_GB=4
CMD ["native-node"] CMD ["native-node"]

View File

@@ -11,18 +11,14 @@ services:
# Käännetään aina käynnistyksen yhteydessä varmuuden vuoksi Wasm uusimmista koodeista, ja päälle pyöräytetään Hub! # Käännetään aina käynnistyksen yhteydessä varmuuden vuoksi Wasm uusimmista koodeista, ja päälle pyöräytetään Hub!
command: bash -c "cd node && wasm-pack build --release --target web --out-dir ../static/pkg && cd ../hub && cargo run" command: bash -c "cd node && wasm-pack build --release --target web --out-dir ../static/pkg && cd ../hub && cargo run"
# Valinnainen natiivi-solmu — kerää oikeat laitteistotiedot (nvidia-smi-taso) # Ollama — LLM-inferenssi GPU:lla (NVIDIA/AMD/Apple)
native-node: ollama:
build: image: ollama/ollama:latest
context: . container_name: kipina_ollama
dockerfile: Dockerfile.native-node ports:
container_name: kipina_native_node - "11434:11434"
environment: volumes:
- HUB_URL=ws://agentic-poc:3000/ws - ollama-models:/root/.ollama
- ALLOCATED_GB=4
depends_on:
- agentic-poc
# GPU passthrough (valinnainen — toimii myös ilman)
deploy: deploy:
resources: resources:
reservations: reservations:
@@ -32,3 +28,23 @@ services:
capabilities: [gpu] capabilities: [gpu]
profiles: profiles:
- native - native
# Natiivisolmu — yhdistää hubiin ja käyttää Ollamaa inferenssiin
native-node:
build:
context: .
dockerfile: Dockerfile.native-node
container_name: kipina_native_node
environment:
- HUB_URL=ws://agentic-poc:3000/ws
- OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=qwen2.5-coder:7b
- ALLOCATED_GB=4
depends_on:
- agentic-poc
- ollama
profiles:
- native
volumes:
ollama-models:

Binary file not shown.

View File

@@ -39,6 +39,7 @@ struct AppState {
ip_connections: Mutex<HashMap<IpAddr, u32>>, ip_connections: Mutex<HashMap<IpAddr, u32>>,
node_ips: Mutex<HashMap<u64, IpAddr>>, node_ips: Mutex<HashMap<u64, IpAddr>>,
node_tasks: Mutex<HashMap<u64, String>>, // node_id → selected_task node_tasks: Mutex<HashMap<u64, String>>, // node_id → selected_task
node_types: Mutex<HashMap<u64, String>>, // node_id → "native" | "browser"
node_busy: Mutex<std::collections::HashSet<u64>>, // Solmut joilla on aktiivinen tehtävä node_busy: Mutex<std::collections::HashSet<u64>>, // Solmut joilla on aktiivinen tehtävä
pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi) pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi)
api_rate_limits: Mutex<HashMap<IpAddr, (std::time::Instant, u32)>>, // IP → (ikkuna-alku, pyyntömäärä) api_rate_limits: Mutex<HashMap<IpAddr, (std::time::Instant, u32)>>, // IP → (ikkuna-alku, pyyntömäärä)
@@ -260,6 +261,7 @@ async fn main() {
ip_connections: Mutex::new(HashMap::new()), ip_connections: Mutex::new(HashMap::new()),
node_ips: Mutex::new(HashMap::new()), node_ips: Mutex::new(HashMap::new()),
node_tasks: Mutex::new(HashMap::new()), node_tasks: Mutex::new(HashMap::new()),
node_types: Mutex::new(HashMap::new()),
node_busy: Mutex::new(std::collections::HashSet::new()), node_busy: Mutex::new(std::collections::HashSet::new()),
pending_task_ids: Mutex::new(std::collections::HashSet::new()), pending_task_ids: Mutex::new(std::collections::HashSet::new()),
api_rate_limits: Mutex::new(HashMap::new()), api_rate_limits: Mutex::new(HashMap::new()),
@@ -382,6 +384,8 @@ async fn main() {
.route("/api/pairs", get(api_pairs)) .route("/api/pairs", get(api_pairs))
.route("/api/stats", get(api_stats)) .route("/api/stats", get(api_stats))
.route("/api/v1/chat/completions", axum::routing::post(api_chat_completions)) .route("/api/v1/chat/completions", axum::routing::post(api_chat_completions))
.route("/api/v1/model", axum::routing::post(api_change_model))
.route("/api/v1/hardware", get(api_hardware))
.route("/admin", get(admin_page)) .route("/admin", get(admin_page))
.nest_service("/", { .nest_service("/", {
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../static".to_string()); let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../static".to_string());
@@ -677,6 +681,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
state.db.insert_session(node_id, &ip.to_string(), node_type, &json); state.db.insert_session(node_id, &ip.to_string(), node_type, &json);
} }
state.node_tasks.lock().unwrap().insert(node_id, selected_task); state.node_tasks.lock().unwrap().insert(node_id, selected_task);
state.node_types.lock().unwrap().insert(node_id, node_type.to_string());
if node_type == "native" { if node_type == "native" {
let sys = json.get("system"); let sys = json.get("system");
@@ -934,6 +939,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
ips.remove(&node_id); ips.remove(&node_id);
vram.remove(&node_id); vram.remove(&node_id);
} }
state.node_types.lock().unwrap().remove(&node_id);
tracing::info!("Solmu {} ({}) poistui verkosta.", node_id, ip); tracing::info!("Solmu {} ({}) poistui verkosta.", node_id, ip);
broadcast_stats(&state).await; broadcast_stats(&state).await;
sender_task.abort(); sender_task.abort();
@@ -943,6 +949,8 @@ struct ChatCompletionRequest {
model: String, model: String,
prompt: String, prompt: String,
task_id: String, task_id: String,
#[serde(default)]
max_tokens: Option<u64>,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -952,6 +960,47 @@ struct ChatCompletionResponse {
tokens_generated: u64, tokens_generated: u64,
} }
async fn api_hardware(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
) -> axum::response::Response {
// Etsitään natiivisolmun GPU-tiedot sessiosta
let sessions = state.db.get_sessions(50);
let native = sessions.iter().find(|s| {
s.get("node_type").and_then(|v| v.as_str()) == Some("native")
});
let (vram_mb, 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("?");
let ram = s.get("system").and_then(|v| v.get("ram_total_mb")).and_then(|v| v.as_u64()).unwrap_or(0);
(vram, name.to_string(), ram)
} else {
(0, "ei natiivisolmua".to_string(), 0)
};
axum::Json(serde_json::json!({
"gpu_name": gpu_name,
"vram_mb": vram_mb,
"ram_mb": ram_mb,
})).into_response()
}
async fn api_change_model(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
axum::Json(payload): axum::Json<serde_json::Value>,
) -> axum::response::Response {
let model = payload.get("model").and_then(|v| v.as_str()).unwrap_or("");
if model.is_empty() {
return (axum::http::StatusCode::BAD_REQUEST, "model puuttuu").into_response();
}
tracing::info!("Mallin vaihto: {}", model);
let msg = serde_json::json!({ "type": "change_model", "model": model });
let _ = state.stats_tx.send(msg.to_string());
axum::Json(serde_json::json!({ "status": "ok", "model": model })).into_response()
}
async fn api_chat_completions( async fn api_chat_completions(
axum::extract::State(state): axum::extract::State<Arc<AppState>>, axum::extract::State(state): axum::extract::State<Arc<AppState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>, ConnectInfo(addr): ConnectInfo<SocketAddr>,
@@ -972,10 +1021,11 @@ async fn api_chat_completions(
} }
} }
// Etsitään vapaa tai varattu solmu, joka vastaa pyydettyä mallia // Etsitään vapaa solmu — priorisoidaan natiivisolmut (GPU) selaimen edelle
let (target_node_free, target_node_any, total_matching) = { let (target_node_free, target_node_any, total_matching) = {
let tasks = state.node_tasks.lock().unwrap(); let tasks = state.node_tasks.lock().unwrap();
let busy = state.node_busy.lock().unwrap(); let busy = state.node_busy.lock().unwrap();
let node_types = state.node_types.lock().unwrap();
let matching: Vec<u64> = tasks.iter().filter(|(_, task)| { let matching: Vec<u64> = tasks.iter().filter(|(_, task)| {
if payload.model == "qwen-coder" { if payload.model == "qwen-coder" {
task.starts_with("qwen-coder") task.starts_with("qwen-coder")
@@ -983,7 +1033,12 @@ async fn api_chat_completions(
**task == payload.model **task == payload.model
} }
}).map(|(k, _)| *k).collect(); }).map(|(k, _)| *k).collect();
let free = matching.iter().find(|id| !busy.contains(id)).copied(); // Vapaat solmut: natiivi ensin, sitten selain
let free_native = matching.iter().find(|id| {
!busy.contains(id) && node_types.get(id).map(|t| t == "native").unwrap_or(false)
}).copied();
let free_any = matching.iter().find(|id| !busy.contains(id)).copied();
let free = free_native.or(free_any);
let any = matching.first().copied(); let any = matching.first().copied();
(free, any, matching.len()) (free, any, matching.len())
}; };
@@ -1059,12 +1114,15 @@ async fn api_chat_completions(
state.node_busy.lock().unwrap().insert(target_node_id); state.node_busy.lock().unwrap().insert(target_node_id);
state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone()); state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone());
let msg = serde_json::json!({ let mut msg = serde_json::json!({
"type": "llm_prompt", "type": "llm_prompt",
"prompt": payload.prompt, "prompt": payload.prompt,
"model": payload.model, "model": payload.model,
"task_id": payload.task_id, "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));
}
// Odotuskanava valmiiksi (solmu palauttaa tuloksen stats_tx kautta) // Odotuskanava valmiiksi (solmu palauttaa tuloksen stats_tx kautta)
let mut rx = state.stats_tx.subscribe(); let mut rx = state.stats_tx.subscribe();

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "native-node" name = "native-node"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -12,10 +12,6 @@ serde_json = "1.0"
sysinfo = "0.30" sysinfo = "0.30"
nvml-wrapper = "0.10" nvml-wrapper = "0.10"
wgpu = "24" wgpu = "24"
candle-core = { version = "0.8", features = ["cuda"] } reqwest = { version = "0.12", features = ["json"] }
candle-nn = "0.8"
candle-transformers = "0.8"
hf-hub = "0.4"
tokenizers = "0.19"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -1,261 +1,114 @@
use candle_core::{Device, Tensor, DType};
use candle_nn::VarBuilder;
use candle_transformers::models::qwen2::{Config as QwenConfig, ModelForCausalLM as QwenModel};
use hf_hub::{api::sync::Api, Repo, RepoType};
use std::time::Instant; use std::time::Instant;
use std::cell::RefCell;
/// Top-k sampling with temperature and repetition penalty
fn sample_top_k(logits: &Tensor, k: usize, temperature: f64, generated_tokens: &[u32], repetition_penalty: f64, rng_state: &mut u64) -> Result<u32, String> {
let mut logits_vec: Vec<f32> = logits.to_vec1::<f32>().map_err(|e| format!("to_vec1: {}", e))?;
if logits_vec.is_empty() { return Err("Tyhjä logits".to_string()); }
// Repetition penalty: rankaisee jo generoituja tokeneita
for &token_id in generated_tokens {
if (token_id as usize) < logits_vec.len() {
let logit = &mut logits_vec[token_id as usize];
if *logit > 0.0 {
*logit /= repetition_penalty as f32;
} else {
*logit *= repetition_penalty as f32;
}
}
}
// Temperature scaling
if temperature > 0.0 && temperature != 1.0 {
for logit in logits_vec.iter_mut() {
*logit /= temperature as f32;
}
}
// Top-k: etsitään k suurinta
let mut indexed: Vec<(usize, f32)> = logits_vec.iter().enumerate().map(|(i, &v)| (i, v)).collect();
indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
indexed.truncate(k);
if k == 1 || temperature == 0.0 {
return Ok(indexed[0].0 as u32);
}
// Softmax top-k:lle
let max_logit = indexed[0].1;
let exps: Vec<f32> = indexed.iter().map(|x| (x.1 - max_logit).exp()).collect();
let sum: f32 = exps.iter().sum();
let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
// XorShift64 RNG
*rng_state ^= *rng_state << 13;
*rng_state ^= *rng_state >> 7;
*rng_state ^= *rng_state << 17;
let rand_val = (*rng_state % 10000) as f32 / 10000.0;
let mut cumulative = 0.0;
for (i, p) in probs.iter().enumerate() {
cumulative += p;
if rand_val < cumulative {
return Ok(indexed[i].0 as u32);
}
}
Ok(indexed[0].0 as u32)
}
pub struct LlmEngine { pub struct LlmEngine {
tokenizer: tokenizers::Tokenizer, ollama_url: String,
model: QwenModel, model: RefCell<String>,
device: Device, client: reqwest::Client,
eos_token: u32,
} }
impl LlmEngine { impl LlmEngine {
pub fn load() -> Result<Self, String> { pub fn load() -> Result<Self, String> {
let device = Device::cuda_if_available(0).map_err(|e| format!("Device: {}", e))?; let ollama_url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let device_name = if device.is_cuda() { "CUDA" } else { "CPU" }; let model = std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "qwen2.5-coder:7b".to_string());
tracing::info!("LLM device: {}", device_name);
let dtype = if device.is_cuda() { DType::F16 } else { DType::F32 }; tracing::info!("Ollama backend: {} | malli: {}", ollama_url, model);
tracing::info!("Ladataan Qwen2.5-Coder-0.5B-Instruct..."); let client = reqwest::Client::builder()
let api = Api::new().map_err(|e| format!("HF API: {}", e))?; .timeout(std::time::Duration::from_secs(600))
let repo = api.repo(Repo::with_revision( .build()
"Qwen/Qwen2.5-Coder-0.5B-Instruct".to_string(), .map_err(|e| format!("HTTP client: {}", e))?;
RepoType::Model,
"main".to_string(),
));
let tokenizer_path = repo.get("tokenizer.json").map_err(|e| format!("Tokenizer lataus: {}", e))?; Ok(LlmEngine { ollama_url, model: RefCell::new(model), client })
let model_path = repo.get("model.safetensors").map_err(|e| format!("Malli lataus: {}", e))?; }
tracing::info!("Ladataan tokenizer: {:?}", tokenizer_path); pub fn model_name(&self) -> String {
let tokenizer = tokenizers::Tokenizer::from_file(&tokenizer_path) self.model.borrow().clone()
.map_err(|e| format!("Tokenizer: {}", e))?; }
let config = QwenConfig { pub fn set_model(&self, new_model: String) {
vocab_size: 151936, *self.model.borrow_mut() = new_model;
hidden_size: 896, }
intermediate_size: 4864,
num_hidden_layers: 24, /// Varmistaa että malli on ladattu Ollamaan (ollama pull)
num_attention_heads: 14, pub async fn ensure_model(&self) -> Result<(), String> {
num_key_value_heads: 2, let model = self.model.borrow().clone();
max_position_embeddings: 32768, tracing::info!("Tarkistetaan malli {}...", model);
sliding_window: 32768, let resp = self.client.post(format!("{}/api/pull", self.ollama_url))
max_window_layers: 21, .json(&serde_json::json!({ "name": model, "stream": false }))
tie_word_embeddings: true, .send()
rope_theta: 1000000.0, .await
rms_norm_eps: 1e-6, .map_err(|e| format!("Ollama pull: {}", e))?;
use_sliding_window: false,
hidden_act: candle_nn::Activation::Silu, if resp.status().is_success() {
}; tracing::info!("Malli {} valmis", model);
Ok(())
} else {
Err(format!("Ollama pull epäonnistui: {}", resp.status()))
}
}
pub async fn generate(&self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> {
let system = "You are a coding assistant. Respond with ONLY code. No explanations, no markdown, no comments unless asked.";
let model = self.model.borrow().clone();
let start = Instant::now(); let start = Instant::now();
let vb = unsafe { let resp = self.client.post(format!("{}/api/generate", self.ollama_url))
VarBuilder::from_mmaped_safetensors(&[model_path.clone()], dtype, &device) .json(&serde_json::json!({
.map_err(|e| format!("VarBuilder: {}", e))? "model": model,
}; "prompt": prompt,
let model = QwenModel::new(&config, vb).map_err(|e| format!("Malli: {}", e))?; "system": system,
tracing::info!("Malli ladattu ({:.1}s) — {}", start.elapsed().as_secs_f64(), device_name); "stream": false,
"options": {
"num_predict": max_tokens,
"temperature": 0.7,
"top_k": 40,
"repeat_penalty": 1.15,
"stop": ["<|im_end|>", "\n###", "\nExplanation", "\nNote:"]
}
}))
.send()
.await
.map_err(|e| format!("Ollama generate: {}", e))?;
Ok(LlmEngine { if !resp.status().is_success() {
tokenizer, return Err(format!("Ollama HTTP {}", resp.status()));
model,
device,
eos_token: 151645,
})
} }
pub fn generate(&mut self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> { let body: serde_json::Value = resp.json().await
// Prefill: aloitetaan vastaus ```-koodiblokkilla → malli jatkaa suoraan koodilla .map_err(|e| format!("Ollama JSON: {}", e))?;
let formatted = format!("<|im_start|>system\nYou are a coding assistant. Respond with ONLY code. No explanations, no markdown, no comments unless asked.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n```\n", prompt);
let encoding = self.tokenizer.encode(formatted.as_str(), true) let text = body["response"].as_str().unwrap_or("").to_string();
.map_err(|e| format!("Encode: {}", e))?; let total_duration_ns = body["total_duration"].as_u64().unwrap_or(0);
let input_ids: Vec<u32> = encoding.get_ids().to_vec(); let eval_count = body["eval_count"].as_u64().unwrap_or(0) as usize;
let input_len = input_ids.len(); let eval_duration_ns = body["eval_duration"].as_u64().unwrap_or(1);
// Nollataan KV-cache edellisestä promptista let duration_ms = start.elapsed().as_millis() as f64;
self.model.clear_kv_cache(); let tokens_per_sec = if eval_duration_ns > 0 {
eval_count as f64 / (eval_duration_ns as f64 / 1_000_000_000.0)
// Sampling-parametrit
let temperature = 0.7;
let top_k = 40;
let repetition_penalty = 1.15;
let mut rng_state: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let start = Instant::now();
// Prefill
let input = Tensor::new(input_ids.as_slice(), &self.device)
.and_then(|t| t.unsqueeze(0))
.map_err(|e| format!("Tensor: {}", e))?;
let logits = self.model.forward(&input, 0)
.map_err(|e| format!("Forward prefill: {}", e))?;
let logits = logits.squeeze(0).map_err(|e| format!("Squeeze: {}", e))?;
let logits = if logits.dims().len() == 2 {
let seq_len = logits.dim(0).map_err(|e| format!("Dim: {}", e))?;
if seq_len == 0 { return Err("Tyhjä tensori".to_string()); }
logits.get(seq_len - 1).map_err(|e| format!("Get: {}", e))?
} else {
logits
};
let mut generated_text = String::new();
let mut tokens_generated: usize = 0;
let mut all_tokens: Vec<u32> = Vec::new();
let mut next_token = sample_top_k(&logits, top_k, temperature, &all_tokens, repetition_penalty, &mut rng_state)?;
if next_token != self.eos_token {
if let Ok(text) = self.tokenizer.decode(&[next_token], true) {
generated_text.push_str(&text);
}
all_tokens.push(next_token);
tokens_generated += 1;
}
// Autoregressive
let mut pos = input_len;
for _ in 1..max_tokens {
if next_token == self.eos_token { break; }
let input = Tensor::new(&[next_token], &self.device)
.and_then(|t| t.unsqueeze(0))
.map_err(|e| format!("Tensor: {}", e))?;
let logits = self.model.forward(&input, pos)
.map_err(|e| format!("Forward pos {}: {}", pos, e))?;
let logits = logits.squeeze(0).map_err(|e| format!("Squeeze: {}", e))?;
let logits = if logits.dims().len() == 2 {
let seq_len = logits.dim(0).map_err(|e| format!("Dim: {}", e))?;
if seq_len == 0 { break; }
logits.get(seq_len - 1).map_err(|e| format!("Get: {}", e))?
} else {
logits
};
next_token = sample_top_k(&logits, top_k, temperature, &all_tokens, repetition_penalty, &mut rng_state)?;
pos += 1;
if next_token == self.eos_token { break; }
if let Ok(text) = self.tokenizer.decode(&[next_token], true) {
generated_text.push_str(&text);
// Stop-sekvenssit: katkaistaan kun malli alkaa selittää
let lower = generated_text.to_lowercase();
if lower.contains("\n###") || lower.contains("\nexplanation") || lower.contains("\nnote:") || lower.contains("\noutput:") || lower.contains("\n```\n\n") || lower.contains("\n// example") || lower.contains("\n# example") {
for stop in &["\n###", "\nExplanation", "\nNote:", "\nOutput:", "\n```\n\n", "\n// Example", "\n// example", "\n# Example", "\n# example"] {
if let Some(pos) = generated_text.find(stop) {
generated_text.truncate(pos);
}
}
break;
}
}
all_tokens.push(next_token);
tokens_generated += 1;
}
let gen_time = start.elapsed();
let tokens_per_sec = if gen_time.as_secs_f64() > 0.0 {
tokens_generated as f64 / gen_time.as_secs_f64()
} else { 0.0 }; } else { 0.0 };
Ok(GenerateResult { Ok(GenerateResult {
text: strip_markdown_wrapper(&generated_text), text: strip_code_fences(&text),
tokens_generated, tokens_generated: eval_count,
duration_ms: gen_time.as_millis() as f64, duration_ms,
tokens_per_sec, tokens_per_sec,
}) })
} }
} }
const LANG_TAGS: &[&str] = &[ /// Siivoa mahdolliset markdown-koodiblokki-merkit
"python", "py", "rust", "rs", "javascript", "js", "typescript", "ts", fn strip_code_fences(text: &str) -> String {
"java", "kotlin", "scala", "go", "ruby", "rb", "php", "swift",
"c", "cpp", "c++", "c#", "csharp", "r", "sql", "bash", "sh", "zsh",
"html", "css", "json", "yaml", "yml", "toml", "xml", "markdown", "md",
"lua", "perl", "dart", "elixir", "haskell", "hs", "ocaml", "zig",
"plaintext", "text", "txt",
];
/// Siivoa mallin tuottama vastaus (prefill-yhteensopiva).
fn strip_markdown_wrapper(text: &str) -> String {
let mut result = text.trim().to_string(); let mut result = text.trim().to_string();
// 1. Kielitunniste — VAIN tunnettu kieli // Poista aloittava ```lang
if result.starts_with("```") {
if let Some(nl) = result.find('\n') { if let Some(nl) = result.find('\n') {
let first = result[..nl].trim().to_lowercase();
if LANG_TAGS.contains(&first.as_str()) {
result = result[nl + 1..].to_string(); result = result[nl + 1..].to_string();
} }
} }
// 2. Sulkeva ``` — VAIN omalla rivillään lopussa // Poista sulkeva ```
let trimmed = result.trim_end(); let trimmed = result.trim_end();
if trimmed.ends_with("```") { if trimmed.ends_with("```") {
let before = &trimmed[..trimmed.len() - 3]; let before = &trimmed[..trimmed.len() - 3];
@@ -264,29 +117,7 @@ fn strip_markdown_wrapper(text: &str) -> String {
} }
} }
// 3. Johdantolauseet result
let lower = result.trim().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;
}
}
// 4. Selityskommentit alusta
let mut lines: Vec<&str> = result.trim().lines().collect();
while !lines.is_empty() {
let first = lines[0].trim();
let is_preamble = first.starts_with("# ") && !first.starts_with("#!")
&& (first.to_lowercase().contains("this is")
|| first.to_lowercase().contains("simple")
|| first.to_lowercase().contains("program that")
|| first.to_lowercase().contains("here is")
|| first.to_lowercase().contains("the following")
|| first.to_lowercase().contains("below"));
if is_preamble { lines.remove(0); } else { break; }
}
lines.join("\n").trim().to_string()
} }
pub struct GenerateResult { pub struct GenerateResult {

View File

@@ -285,15 +285,19 @@ async fn main() {
} }
} }
// Ladataan LLM-malli // Ollama-backend
tracing::info!("Ladataan LLM-mallia..."); tracing::info!("Alustetaan Ollama-yhteyttä...");
let mut llm = match inference::LlmEngine::load() { let llm = match inference::LlmEngine::load() {
Ok(engine) => { Ok(engine) => {
tracing::info!("LLM valmis inferenssiin!"); // Varmistetaan malli (ollama pull) — odotetaan kunnes valmis
match engine.ensure_model().await {
Ok(()) => tracing::info!("Ollama valmis inferenssiin!"),
Err(e) => tracing::warn!("Mallin lataus: {} — yritetään silti", e),
}
Some(engine) Some(engine)
} }
Err(e) => { Err(e) => {
tracing::warn!("LLM-lataus epäonnistui: {} — toimitaan ilman inferenssiä", e); tracing::warn!("Ollama-alustus epäonnistui: {} — toimitaan ilman inferenssiä", e);
None None
} }
}; };
@@ -324,11 +328,13 @@ async fn main() {
if !prompt.is_empty() && msg_model.starts_with("qwen-coder") { if !prompt.is_empty() && msg_model.starts_with("qwen-coder") {
if let Some(ref mut engine) = llm { if let Some(ref engine) = llm {
busy = true; busy = true;
tracing::info!("Generoidaan (task_id: {}): \"{}\"", task_id, prompt); let max_tokens = task.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(512) as usize;
tracing::info!("Generoidaan (task_id: {}, max_tokens: {}): \"{}\"", task_id, max_tokens, &prompt[..prompt.len().min(100)]);
match engine.generate(prompt, 64) { let model_name = engine.model_name();
match engine.generate(prompt, max_tokens).await {
Ok(result) => { Ok(result) => {
tracing::info!( tracing::info!(
"Tulos: {} tokenia | {:.0}ms | {:.1} tok/s | \"{}\"", "Tulos: {} tokenia | {:.0}ms | {:.1} tok/s | \"{}\"",
@@ -341,7 +347,7 @@ async fn main() {
let done = json!({ let done = json!({
"type": "llm_done", "type": "llm_done",
"prompt": prompt, "prompt": prompt,
"model": "Qwen2.5-Coder-0.5B (native/GPU)", "model": format!("{} (Ollama)", model_name),
"response": result.text, "response": result.text,
"tokens_generated": result.tokens_generated, "tokens_generated": result.tokens_generated,
"duration_ms": result.duration_ms, "duration_ms": result.duration_ms,
@@ -360,7 +366,21 @@ async fn main() {
} }
} }
} }
// Ohitetaan pair_task, stats jne. // 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),
}
}
}
}
}
} }
} }
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s..."); tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");

View File

@@ -1157,7 +1157,7 @@
coder: { name: 'Koodari — System Prompt', model: 'qwen-coder', default: 'Olet kokenut ohjelmistokehittäjä. Kirjoita selkeää, testattavaa koodia ja vastaa aina koodilla.' }, coder: { name: 'Koodari — System Prompt', model: 'qwen-coder', default: 'Olet kokenut ohjelmistokehittäjä. Kirjoita selkeää, testattavaa koodia ja vastaa aina koodilla.' },
data: { name: 'Data-Agentti — System Prompt', model: 'qwen-coder', default: 'Olet tietokanta-asiantuntija. Vastaat skeemojen suunnittelusta, SQL-kyselyiden optimoinnista ja datamalleista.' }, data: { name: 'Data-Agentti — System Prompt', model: 'qwen-coder', default: 'Olet tietokanta-asiantuntija. Vastaat skeemojen suunnittelusta, SQL-kyselyiden optimoinnista ja datamalleista.' },
qa: { name: 'QA — System Prompt', model: 'qwen-coder', default: 'Olet laadunvarmistaja (QA). Kirjoitat testejä, etsit virheitä ja varmistat, että kaikki reunatapaukset on huomioitu.' }, qa: { name: 'QA — System Prompt', model: 'qwen-coder', default: 'Olet laadunvarmistaja (QA). Kirjoitat testejä, etsit virheitä ja varmistat, että kaikki reunatapaukset on huomioitu.' },
tester: { name: 'DevOps — System Prompt', model: 'qwen-coder', default: 'Olet DevOps-insinööri. Vastaat koodin julkaisuputkista, serveri-infrastruktuurista ja ympäristön suorituskyvystä.' }, tester: { name: 'DevOps — System Prompt', model: 'qwen-coder', default: 'Olet DevOps-insinööri. Kirjoitat Dockerfile- ja docker-compose.yml-tiedostot, README:t ja käynnistysohjeet. Käytä aina multi-stage Docker buildia ja docker compose -orkestrointia.' },
}; };
const selectedAgents = new Set(); const selectedAgents = new Set();
let sharedPrompt = localStorage.getItem('kpn-shared-prompt') || ''; let sharedPrompt = localStorage.getItem('kpn-shared-prompt') || '';
@@ -1672,15 +1672,28 @@
if (e.key === 'Enter') sendUserText(); if (e.key === 'Enter') sendUserText();
}); });
// Kytkemme sivuston UI-puolen (JS) omaan passiiviseen WebSocket-kuuntelijaan. // WebSocket-yhteys hubiin — automaattinen reconnect
const uiSocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`); const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
let uiSocket = null;
let wsReconnectTimer = null;
function connectHub() {
if (uiSocket && (uiSocket.readyState === 0 || uiSocket.readyState === 1)) return;
uiSocket = new WebSocket(wsUrl);
window._uiSocket = uiSocket; window._uiSocket = uiSocket;
// Kytketään onmessage uudelleen (handler määritellään myöhemmin, asetetaan kun valmis)
setTimeout(() => {
if (window._wsMessageHandler) uiSocket.onmessage = window._wsMessageHandler;
}, 0);
uiSocket.onopen = async () => { uiSocket.onopen = async () => {
// Päivitetään agents-näkymän hub-status // Päivitetään agents-näkymän hub-status
const hubDot = document.getElementById('agent-hub-dot'); const hubDot = document.getElementById('agent-hub-dot');
const hubLabel = document.getElementById('agent-hub-label'); const hubLabel = document.getElementById('agent-hub-label');
const hubStatus = document.getElementById('agent-hub-status'); const hubStatus = document.getElementById('agent-hub-status');
if (hubDot) hubDot.style.background = '#3fb950'; if (hubDot) hubDot.style.background = '#3fb950';
// Poistetaan reconnect-rivi
const reconnLine = document.getElementById('agent-terminal')?.querySelector('.term-reconnect');
if (reconnLine) reconnLine.remove();
if (hubLabel) { hubLabel.textContent = 'Yhdistetty'; hubLabel.style.color = '#3fb950'; } if (hubLabel) { hubLabel.textContent = 'Yhdistetty'; hubLabel.style.color = '#3fb950'; }
if (hubStatus) hubStatus.title = 'Yhdistetty Kipinä Hubiin — tehtävien jakelu ja solmujen koordinointi aktiivinen'; if (hubStatus) hubStatus.title = 'Yhdistetty Kipinä Hubiin — tehtävien jakelu ja solmujen koordinointi aktiivinen';
@@ -1746,7 +1759,31 @@
coderEl.textContent = 'Disconnected'; coderEl.textContent = 'Disconnected';
coderEl.style.color = '#f85149'; coderEl.style.color = '#f85149';
} }
// Automaattinen reconnect 3s kuluttua
if (!wsReconnectTimer) {
// Päivitetään samaa riviä eikä floodata uusia
let reconnLine = termPanel?.querySelector('.term-reconnect');
let reconnCount = 0;
if (!reconnLine) {
reconnLine = document.createElement('div');
reconnLine.className = 'terminal-line term-reconnect';
termPanel?.appendChild(reconnLine);
} else {
reconnCount = parseInt(reconnLine.dataset.count || '0');
}
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
reconnCount++;
reconnLine.dataset.count = reconnCount;
reconnLine.innerHTML = ` <span style="color:#d29922">↻ Yhdistetään uudelleen...${reconnCount > 1 ? ' (' + reconnCount + ')' : ''}</span>`;
termPanel.scrollTop = termPanel.scrollHeight;
connectHub();
}, 3000);
}
}; };
} // connectHub()
connectHub();
// Terminaalin komentorivi // Terminaalin komentorivi
const termInput = document.getElementById('term-input'); const termInput = document.getElementById('term-input');
const termPanel = document.getElementById('agent-terminal'); const termPanel = document.getElementById('agent-terminal');
@@ -1767,7 +1804,7 @@
const activeStreams = {}; const activeStreams = {};
// Lähettää promptin mallille ja palauttaa vastauksen (tai null virhetilanteessa) // Lähettää promptin mallille ja palauttaa vastauksen (tai null virhetilanteessa)
async function kpnRun(model, prompt, silent) { async function kpnRun(model, prompt, silent, maxTokens) {
const taskId = crypto.randomUUID(); const taskId = crypto.randomUUID();
// Yksittäinen status-rivi jota päivitetään läpi pyynnön elinkaaren // Yksittäinen status-rivi jota päivitetään läpi pyynnön elinkaaren
const statusDiv = document.createElement('div'); const statusDiv = document.createElement('div');
@@ -1801,7 +1838,7 @@
const res = await fetch('/api/v1/chat/completions', { const res = await fetch('/api/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: fullPrompt, task_id: taskId }), body: JSON.stringify({ model, prompt: fullPrompt, task_id: taskId, ...(maxTokens ? { max_tokens: maxTokens } : {}) }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -1905,11 +1942,15 @@
} }
// Projektikortti: tiedostovälilehdet + kopioi + lataa ZIP // Projektikortti: tiedostovälilehdet + kopioi + lataa ZIP
// Globaali storage projektikorttien tiedostoille (välttää JSON data-attribuuttien ongelmat)
const projectFiles = {};
function renderProjectCard(files, projectName) { function renderProjectCard(files, projectName) {
const fileEntries = Object.entries(files); const fileEntries = Object.entries(files);
if (fileEntries.length === 0) return; if (fileEntries.length === 0) return;
const cardId = 'proj-' + Date.now(); const cardId = 'proj-' + Date.now();
projectFiles[cardId] = files;
const tabsHtml = fileEntries.map(([name], i) => const tabsHtml = fileEntries.map(([name], i) =>
`<span class="proj-tab" data-card="${cardId}" data-idx="${i}" style="padding:4px 10px;cursor:pointer;border-radius:4px 4px 0 0;font-size:12px;${i === 0 ? 'background:#161b22;color:#58a6ff;border:1px solid #30363d;border-bottom:none' : 'color:#8b949e'}" onclick="switchProjectTab('${cardId}',${i})">${esc(name)}</span>` `<span class="proj-tab" data-card="${cardId}" data-idx="${i}" style="padding:4px 10px;cursor:pointer;border-radius:4px 4px 0 0;font-size:12px;${i === 0 ? 'background:#161b22;color:#58a6ff;border:1px solid #30363d;border-bottom:none' : 'color:#8b949e'}" onclick="switchProjectTab('${cardId}',${i})">${esc(name)}</span>`
).join(''); ).join('');
@@ -1926,7 +1967,7 @@
const allText = fileEntries.map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n'); const allText = fileEntries.map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n');
const cardHtml = ` const cardHtml = `
<div id="${cardId}" style="margin:8px 0;border:1px solid #30363d;border-radius:6px;background:#161b22;overflow:hidden" data-files='${esc(JSON.stringify(files))}'> <div id="${cardId}" style="margin:8px 0;border:1px solid #30363d;border-radius:6px;background:#161b22;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d"> <div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d">
<span style="color:#a371f7;font-weight:600;font-size:13px">${esc(projectName || 'Projekti')} <span style="color:#8b949e;font-weight:normal">(${fileEntries.length} tiedostoa)</span></span> <span style="color:#a371f7;font-weight:600;font-size:13px">${esc(projectName || 'Projekti')} <span style="color:#8b949e;font-weight:normal">(${fileEntries.length} tiedostoa)</span></span>
<span style="display:flex;gap:6px"> <span style="display:flex;gap:6px">
@@ -1960,7 +2001,7 @@
window.copyFileContent = function(cardId, idx) { window.copyFileContent = function(cardId, idx) {
const card = document.getElementById(cardId); const card = document.getElementById(cardId);
if (!card) return; if (!card) return;
const files = JSON.parse(card.dataset.files); const files = projectFiles[cardId];
const entries = Object.entries(files); const entries = Object.entries(files);
if (entries[idx]) { if (entries[idx]) {
navigator.clipboard.writeText(entries[idx][1]); navigator.clipboard.writeText(entries[idx][1]);
@@ -1973,7 +2014,7 @@
window.copyAllFiles = function(cardId) { window.copyAllFiles = function(cardId) {
const card = document.getElementById(cardId); const card = document.getElementById(cardId);
if (!card) return; if (!card) return;
const files = JSON.parse(card.dataset.files); const files = projectFiles[cardId];
const text = Object.entries(files).map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n'); const text = Object.entries(files).map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n');
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
const btn = card.querySelector('[onclick*="copyAllFiles"]'); const btn = card.querySelector('[onclick*="copyAllFiles"]');
@@ -1983,9 +2024,20 @@
window.downloadZip = async function(cardId) { window.downloadZip = async function(cardId) {
const card = document.getElementById(cardId); const card = document.getElementById(cardId);
if (!card) return; if (!card) return;
const files = JSON.parse(card.dataset.files); const files = projectFiles[cardId];
// CRC-32 laskenta ZIP-tiedostoille
function crc32(bytes) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc ^= bytes[i];
for (let j = 0; j < 8; j++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
}
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
// Luodaan ZIP ilman ulkoisia kirjastoja (yksinkertainen uncompressed ZIP)
const entries = Object.entries(files); const entries = Object.entries(files);
const parts = []; const parts = [];
const centralDir = []; const centralDir = [];
@@ -1994,6 +2046,7 @@
for (const [name, content] of entries) { for (const [name, content] of entries) {
const nameBytes = new TextEncoder().encode(name); const nameBytes = new TextEncoder().encode(name);
const contentBytes = new TextEncoder().encode(content); const contentBytes = new TextEncoder().encode(content);
const crc = crc32(contentBytes);
// Local file header // Local file header
const header = new Uint8Array(30 + nameBytes.length); const header = new Uint8Array(30 + nameBytes.length);
@@ -2001,8 +2054,9 @@
view.setUint32(0, 0x04034b50, true); // Signature view.setUint32(0, 0x04034b50, true); // Signature
view.setUint16(4, 20, true); // Version needed view.setUint16(4, 20, true); // Version needed
view.setUint16(8, 0, true); // Method: store view.setUint16(8, 0, true); // Method: store
view.setUint32(18, contentBytes.length, true); // Compressed size view.setUint32(14, crc, true); // CRC-32
view.setUint32(22, contentBytes.length, true); // Uncompressed size view.setUint32(18, contentBytes.length, true);
view.setUint32(22, contentBytes.length, true);
view.setUint16(26, nameBytes.length, true); view.setUint16(26, nameBytes.length, true);
header.set(nameBytes, 30); header.set(nameBytes, 30);
@@ -2012,6 +2066,7 @@
cdView.setUint32(0, 0x02014b50, true); cdView.setUint32(0, 0x02014b50, true);
cdView.setUint16(4, 20, true); cdView.setUint16(4, 20, true);
cdView.setUint16(6, 20, true); cdView.setUint16(6, 20, true);
cdView.setUint32(16, crc, true); // CRC-32
cdView.setUint32(20, contentBytes.length, true); cdView.setUint32(20, contentBytes.length, true);
cdView.setUint32(24, contentBytes.length, true); cdView.setUint32(24, contentBytes.length, true);
cdView.setUint16(28, nameBytes.length, true); cdView.setUint16(28, nameBytes.length, true);
@@ -2055,17 +2110,18 @@
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`); termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`);
pipelineStep('manager', 'Suunnittelu', 'active', task); pipelineStep('manager', 'Suunnittelu', 'active', task);
const managerPrompt = `List the source files needed for this project. One file per line, format: const managerPrompt = `List the source files needed for this project. One file per line, format:
filename.py: what this file contains filename.py: one-line description
Rules: CONSTRAINTS — the coder can only generate ~400 tokens per file:
- Max 4 files - Max 3 files (keep it minimal)
- Only .py, .toml, .json, .html files - Each file must be SHORT: one clear responsibility, no boilerplate
- Only .py and pyproject.toml files
- No directories, no paths, just filenames - No directories, no paths, just filenames
- List dependencies first, then main app (e.g. models.py before main.py) - List dependencies first, then main app
- Use pyproject.toml for dependencies (not requirements.txt) - Prefer fewer, focused files over many small ones
Project: ${task}`; Project: ${task}`;
const plan = await kpnRun(agentPrompts.manager.model, managerPrompt); const plan = await kpnRun(agentPrompts.manager.model, managerPrompt, false, 200);
if (!plan) { termLog(' ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; } if (!plan) { termLog(' ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; }
pipelineStep('manager', 'Suunnittelu', 'done', task, plan); pipelineStep('manager', 'Suunnittelu', 'done', task, plan);
@@ -2133,7 +2189,7 @@ start = "uvicorn main:app --reload"`;
const coderPrompt = `${context}Project: ${task} const coderPrompt = `${context}Project: ${task}
Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions} Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions}
Use the exact libraries mentioned in the project description. Write correct, working code.`; IMPORTANT: Keep the code SHORT and focused. Max ~50 lines. No comments, no docstrings, no type hints unless essential. Write minimal, working code.`;
const code = await kpnRun(agentPrompts.coder.model, coderPrompt); const code = await kpnRun(agentPrompts.coder.model, coderPrompt);
if (!code) { if (!code) {
termLog(` ✗ Pipeline keskeytyi (${file.name})`, '#f85149'); termLog(` ✗ Pipeline keskeytyi (${file.name})`, '#f85149');
@@ -2154,7 +2210,7 @@ Use the exact libraries mentioned in the project description. Write correct, wor
If the code is correct, say "LGTM". If the code is correct, say "LGTM".
${allCode}`; ${allCode}`;
const review = await kpnRun(agentPrompts.tester.model, reviewPrompt); const review = await kpnRun(agentPrompts.tester.model, reviewPrompt, false, 200);
pipelineStep('tester', 'Review', 'done', `${Object.keys(generatedFiles).length} tiedostoa`, review); pipelineStep('tester', 'Review', 'done', `${Object.keys(generatedFiles).length} tiedostoa`, review);
// Vaihe 4: Korjausluuppi — jos testaaja löysi ongelmia // Vaihe 4: Korjausluuppi — jos testaaja löysi ongelmia
@@ -2173,11 +2229,75 @@ Write the corrected code.`;
if (fixedCode) { if (fixedCode) {
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 4}] Testaaja</span> — uudelleenarviointi`); termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 4}] Testaaja</span> — uudelleenarviointi`);
pipelineStep('tester', 'Re-review', 'active', fixedCode); pipelineStep('tester', 'Re-review', 'active', fixedCode);
const reReview = await kpnRun(agentPrompts.tester.model, `Review the corrected code briefly:\n${fixedCode}`); const reReview = await kpnRun(agentPrompts.tester.model, `Review the corrected code briefly:\n${fixedCode}`, false, 128);
pipelineStep('tester', 'Re-review', 'done', fixedCode, reReview); pipelineStep('tester', 'Re-review', 'done', fixedCode, reReview);
} }
} }
// Vaihe 5: QA kirjoittaa testit
const step5 = fileList.length + (review && !review.toLowerCase().includes('lgtm') ? 5 : 3);
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step5}] QA</span> — testit`);
pipelineStep('qa', 'Testit', 'active', 'Kirjoitetaan testejä');
const qaPrompt = `Write a short test file (test_app.py) for this project. Use pytest. Max 3 test functions. Keep it minimal.
${Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n')}`;
const tests = await kpnRun(agentPrompts.qa.model, qaPrompt, false, 512);
if (tests) generatedFiles['test_app.py'] = tests;
pipelineStep('qa', 'Testit', 'done', 'test_app.py', tests);
// Vaihe 6: DevOps — Dockerfile
const step6 = step5 + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step6}] DevOps</span> — Dockerfile`);
pipelineStep('tester', 'Dockerfile', 'active', 'Dockerfile');
const mainFile = Object.keys(generatedFiles).find(f => f.includes('main') || f.includes('app')) || Object.keys(generatedFiles)[0];
const dockerPrompt = `Write a Dockerfile for this Python project using uv package manager.
RULES:
- Base: python:3.12-slim
- Install uv: COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
- COPY pyproject.toml and then: RUN uv sync --no-dev
- COPY all .py files
- EXPOSE 8000
- CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]
- Only output Dockerfile content, no explanations
Files: ${Object.keys(generatedFiles).join(', ')}`;
const dockerfile = await kpnRun(agentPrompts.tester.model, dockerPrompt, false, 256);
if (dockerfile) generatedFiles['Dockerfile'] = dockerfile;
pipelineStep('tester', 'Dockerfile', 'done', 'Dockerfile', dockerfile);
// Vaihe 7: DevOps — docker-compose.yml
const step7 = step6 + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step7}] DevOps</span> — docker-compose.yml`);
pipelineStep('tester', 'Compose', 'active', 'docker-compose.yml');
const composePrompt = `Write a docker-compose.yml for this project. Include:
- app service (build from Dockerfile, port mapping, restart: unless-stopped)
- db service if SQLite/PostgreSQL is used (volume for data persistence)
- Named volumes for persistent data
Only output the YAML content, nothing else.
Files: ${Object.keys(generatedFiles).join(', ')}`;
const compose = await kpnRun(agentPrompts.tester.model, composePrompt, false, 256);
if (compose) generatedFiles['docker-compose.yml'] = compose;
pipelineStep('tester', 'Compose', 'done', 'docker-compose.yml', compose);
// Vaihe 8: DevOps — README
const step8 = step7 + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step8}] DevOps</span> — README`);
pipelineStep('tester', 'README', 'active', 'README.md');
const readmePrompt = `Write a minimal README.md. Include ONLY:
1. One-line description
2. Quick start: docker compose up
3. Development: uv sync && uv run uvicorn main:app --reload
4. API endpoints (if applicable)
5. Testing: uv run pytest
Max 20 lines.
Files: ${Object.keys(generatedFiles).join(', ')}`;
const readme = await kpnRun(agentPrompts.tester.model, readmePrompt, false, 256);
if (readme) generatedFiles['README.md'] = readme;
pipelineStep('tester', 'README', 'done', 'README.md', readme);
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis (${Object.keys(generatedFiles).length} tiedostoa) ━━━</span>`); termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis (${Object.keys(generatedFiles).length} tiedostoa) ━━━</span>`);
renderProjectCard(generatedFiles, task); renderProjectCard(generatedFiles, task);
} }
@@ -2196,11 +2316,73 @@ Write the corrected code.`;
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`); termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`);
} }
// Autokorjaus: tunnetut kirjoitusvirheet ja lähimmän komennon ehdotus
function autocorrect(input) {
const typos = {
'knp': 'kpn', 'kpb': 'kpn', 'kpm': 'kpn', 'kn': 'kpn', 'kp': 'kpn',
'kpn rnu': 'kpn run', 'kpn rn': 'kpn run', 'kpn ru': 'kpn run',
'kpn laod': 'kpn load', 'kpn lod': 'kpn load', 'kpn loa': 'kpn load',
'kpn porject': 'kpn project', 'kpn projcet': 'kpn project', 'kpn proejct': 'kpn project',
'kpn pipelien': 'kpn pipeline', 'kpn pipline': 'kpn pipeline',
'kpn staus': 'kpn status', 'kpn stauts': 'kpn status',
'kpn modles': 'kpn models', 'kpn mdoels': 'kpn models',
'kpn hlep': 'kpn help', 'kpn hep': 'kpn help',
'kpn clera': 'kpn clear', 'kpn claer': 'kpn clear',
'kpn helo': 'kpn hello', 'kpn hell': 'kpn hello',
};
// Tarkista koko komento ja ensimmäinen sana + alikomento
const lower = input.toLowerCase();
for (const [typo, fix] of Object.entries(typos)) {
if (lower === typo || lower.startsWith(typo + ' ')) {
return fix + input.slice(typo.length);
}
}
// Levenshtein-etäisyys ensimmäiselle sanalle
const words = input.trim().split(/\s+/);
const firstWord = words[0].toLowerCase();
if (firstWord !== 'kpn' && firstWord.length >= 2 && firstWord.length <= 5) {
const dist = levenshtein(firstWord, 'kpn');
if (dist <= 2) return 'kpn' + input.slice(firstWord.length);
}
// Fuzzy-korjaus alikomentotasolla: "kpn rnu" → "kpn run"
if (firstWord === 'kpn' && words.length >= 2) {
const sub = words[1].toLowerCase();
const subCommands = ['help', 'run', 'project', 'pipeline', 'load', 'status', 'models', 'hello', 'clear'];
let bestMatch = null, bestDist = 3;
for (const cmd of subCommands) {
const d = levenshtein(sub, cmd);
if (d > 0 && d < bestDist) { bestDist = d; bestMatch = cmd; }
}
if (bestMatch) {
words[1] = bestMatch;
return words.join(' ');
}
}
return null;
}
function levenshtein(a, b) {
const m = a.length, n = b.length;
const d = Array.from({length: m + 1}, (_, i) => [i]);
for (let j = 1; j <= n; j++) d[0][j] = j;
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
d[i][j] = Math.min(d[i-1][j] + 1, d[i][j-1] + 1, d[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0));
return d[m][n];
}
function termExec(cmd) { function termExec(cmd) {
termLog(`<span class="terminal-prompt">$</span> ${esc(cmd)}`); termLog(`<span class="terminal-prompt">$</span> ${esc(cmd)}`);
termHistory.unshift(cmd); termHistory.unshift(cmd);
termHistIdx = -1; termHistIdx = -1;
// Autokorjaus
const corrected = autocorrect(cmd.trim());
if (corrected && corrected !== cmd.trim()) {
cmd = corrected;
termLog(` <span style="color:#d29922">→ korjattu: ${esc(cmd)}</span>`);
}
const parts = cmd.trim().split(/\s+/); const parts = cmd.trim().split(/\s+/);
if (parts[0] !== 'kpn') { if (parts[0] !== 'kpn') {
termLog('kpn: tuntematon komento. Kokeile: kpn help', '#f85149'); termLog('kpn: tuntematon komento. Kokeile: kpn help', '#f85149');
@@ -2227,38 +2409,81 @@ Write the corrected code.`;
if (sub === 'load') { if (sub === 'load') {
const arg = parts[2]; const arg = parts[2];
const btn = document.getElementById('agent-compute-btn'); const ollamaModels = [
// Mallikatalogista valinta numerolla tai nimellä { id: '1', name: 'qwen2.5-coder:0.5b', size: '~400 MB', vram_mb: 0, type: 'selain + Ollama' },
const loadModels = [ { id: '2', name: 'qwen2.5-coder:1.5b', size: '~1 GB', vram_mb: 1500, type: 'Ollama GPU' },
{ id: '1', key: '05b', name: 'Qwen2.5-Coder:0.5B', size: '~990 MB', coderSize: '05b' }, { id: '3', name: 'qwen2.5-coder:7b', size: '~4.7 GB', vram_mb: 5500, type: 'Ollama GPU', default: true },
{ id: '2', key: '3b', name: 'Qwen2.5-Coder:1.5B Q4', size: '~1 GB', coderSize: '3b' }, { id: '4', name: 'qwen2.5-coder:14b', size: '~9 GB', vram_mb: 10000, type: 'Ollama GPU' },
{ id: '5', name: 'qwen2.5-coder:32b', size: '~20 GB', vram_mb: 21000, type: 'Ollama GPU' },
]; ];
if (!arg) { if (!arg) {
// Näytetään lista // Haetaan laitteistotiedot ja näytetään sopivat mallit
termLog(' Ladattavat mallit:', '#c9d1d9'); fetch('/api/v1/hardware').then(r => r.json()).then(hw => {
for (const m of loadModels) { const vram = hw.vram_mb || 0;
const active = (btn?.dataset.state === 'ready' && coderSize === m.coderSize) ? ' <span style="color:#3fb950">✓ ladattu</span>' : ''; const ram = hw.ram_mb || 0;
termLog(` <span style="color:#58a6ff">${m.id}</span> ${m.name} <span style="color:#8b949e">(${m.size})</span>${active}`); const gpu = hw.gpu_name || '?';
const available = vram || ram; // CPU-fallback käyttää RAM:ia
if (vram > 0) {
termLog(` <span style="color:#8b949e">GPU: ${gpu} | VRAM: ${Math.round(vram/1024)} GB | RAM: ${Math.round(ram/1024)} GB</span>`);
} else if (ram > 0) {
termLog(` <span style="color:#8b949e">Ei GPU:ta | RAM: ${Math.round(ram/1024)} GB (CPU-moodi)</span>`);
}
termLog(' Mallit:', '#c9d1d9');
for (const m of ollamaModels) {
const fits = m.vram_mb === 0 || m.vram_mb < available;
const active = m.default ? ' <span style="color:#3fb950">← aktiivinen</span>' : '';
const icon = fits ? `<span style="color:#58a6ff">${m.id}</span>` : `<span style="color:#8b949e;text-decoration:line-through">${m.id}</span>`;
const warn = !fits ? ' <span style="color:#f85149">⚠ ei mahdu</span>' : '';
termLog(` ${icon} ${fits ? '' : '<span style="color:#8b949e">'}${m.name} ${m.size} | ${m.type}${fits ? '' : '</span>'}${active}${warn}`);
} }
termLog(' Käyttö: kpn load &lt;numero&gt;', '#8b949e'); termLog(' Käyttö: kpn load &lt;numero&gt;', '#8b949e');
}).catch(() => {
termLog(' Mallit:', '#c9d1d9');
for (const m of ollamaModels) {
const active = m.default ? ' <span style="color:#3fb950">← aktiivinen</span>' : '';
termLog(` <span style="color:#58a6ff">${m.id}</span> ${m.name} <span style="color:#8b949e">${m.size} | ${m.type}</span>${active}`);
}
termLog(' Käyttö: kpn load &lt;numero&gt;', '#8b949e');
});
return; return;
} }
const selected = loadModels.find(m => m.id === arg || m.key === arg || m.coderSize === arg); const selected = ollamaModels.find(m => m.id === arg || m.name === arg);
if (!selected) { if (!selected) {
termLog(` Tuntematon malli "${esc(arg)}". Kokeile: kpn load`, '#f85149'); termLog(` Tuntematon malli "${esc(arg)}". Kokeile: kpn load`, '#f85149');
return; return;
} }
if (btn?.dataset.state === 'ready' && coderSize === selected.coderSize) { // Selain-WASM (vain 0.5b)
termLog(`${selected.name} on jo ladattu ja valmis`, '#3fb950'); if (selected.id === '1') {
const btn = document.getElementById('agent-compute-btn');
if (btn?.dataset.state === 'ready') {
termLog(' ✓ Qwen2.5-Coder:0.5B on jo ladattu (selain)', '#3fb950');
return; return;
} }
coderSize = selected.coderSize; coderSize = '05b';
localStorage.setItem('kpn-coder-size', coderSize); termLog(' Ladataan Qwen2.5-Coder:0.5B selaimeen...', '#d29922');
termLog(` Alustetaan ${selected.name} (${selected.size})...`, '#d29922');
if (btn) btn.click(); if (btn) btn.click();
else ensureCoderNode(); else ensureCoderNode();
return; return;
} }
// Ollama: vaihdetaan malli hubin kautta
termLog(` Vaihdetaan Ollama-malli: ${selected.name} (${selected.size})...`, '#d29922');
fetch('/api/v1/model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: selected.name }),
}).then(r => r.json()).then(data => {
if (data.status === 'ok') {
termLog(` <span style="color:#3fb950">✓</span> Malli vaihdettu: ${selected.name}`, '#3fb950');
termLog(' <span style="color:#8b949e">Ollama lataa mallin ensimmäisellä pyynnöllä</span>');
// Päivitetään aktiivinen default
ollamaModels.forEach(m => m.default = false);
selected.default = true;
} else {
termLog(` ✗ Mallin vaihto epäonnistui`, '#f85149');
}
}).catch(e => termLog(`${e.message}`, '#f85149'));
return;
}
if (sub === 'status') { if (sub === 'status') {
const nodes = statNodes.textContent || '0'; const nodes = statNodes.textContent || '0';
@@ -2268,14 +2493,14 @@ Write the corrected code.`;
} }
if (sub === 'models') { if (sub === 'models') {
termLog(' Käytettävissä olevat mallit:', '#c9d1d9'); termLog(' <span style="color:#d29922">Selain (kpn load):</span>', '#c9d1d9');
termLog(' <span style="color:#58a6ff">1</span> qwen-coder Qwen2.5-Coder:0.5B <span style="color:#8b949e">~990 MB | koodin generointi</span>'); termLog(' qwen-coder:0.5b <span style="color:#8b949e">~990 MB | WASM ~0.4 tok/s</span>');
termLog(' <span style="color:#58a6ff">2</span> qwen-coder-3b Qwen2.5-Coder:1.5B Q4 <span style="color:#8b949e">~1 GB | kvantisoidtu, parempi laatu</span>'); termLog(' <span style="color:#3fb950">Natiivi (Ollama + GPU):</span>', '#c9d1d9');
termLog(' <span style="color:#58a6ff">3</span> smollm-135m SmolLM 135M <span style="color:#8b949e">~270 MB | kevyt, nopea</span>'); termLog(' qwen2.5-coder:7b <span style="color:#8b949e">~4.7 GB | NVIDIA ~80 tok/s | AMD ~40 tok/s | Apple ~30 tok/s</span>');
termLog(' <span style="color:#58a6ff">4</span> qwen-05b Qwen2.5:0.5B <span style="color:#8b949e">~990 MB | yleismalli</span>'); termLog(' qwen2.5-coder:3b <span style="color:#8b949e">~1.9 GB | NVIDIA ~120 tok/s</span>');
termLog(' <span style="color:#58a6ff">5</span> phi3-mini Phi-3 Mini <span style="color:#8b949e">~2.2 GB | Microsoftin malli</span>'); termLog(' qwen2.5-coder:1.5b <span style="color:#8b949e">~1 GB | NVIDIA ~150 tok/s</span>');
termLog(' Käyttö: kpn run &lt;malli&gt; "&lt;prompti&gt;"', '#8b949e'); termLog(' Vaihda malli: <span style="color:#58a6ff">OLLAMA_MODEL=qwen2.5-coder:7b</span>', '#8b949e');
termLog(' Lataus: kpn load &lt;numero&gt;', '#8b949e'); termLog(' Hub reitittää automaattisesti nopeimmalle solmulle', '#8b949e');
return; return;
} }
@@ -2352,6 +2577,13 @@ Write the corrected code.`;
}; };
function tabComplete(input) { function tabComplete(input) {
// Autokorjaus ensin: korjaa typo ja palauta true jos korjattiin
const corrected = autocorrect(input.value.trim());
if (corrected && corrected !== input.value.trim()) {
input.value = corrected;
return true;
}
const val = input.value; const val = input.value;
const words = val.trimEnd().split(/\s+/); const words = val.trimEnd().split(/\s+/);
@@ -2512,7 +2744,14 @@ Write the corrected code.`;
} }
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
// Näytä dropdown tai täydennä jos vain yksi vaihtoehto // 1. Autokorjaus ensin
const corrected = autocorrect(termInput.value.trim());
if (corrected && corrected !== termInput.value.trim()) {
termInput.value = corrected;
hideDropdown();
return;
}
// 2. Dropdown / täydennys
const { items, prefix } = getCandidates(termInput.value); const { items, prefix } = getCandidates(termInput.value);
if (items.length === 1) { if (items.length === 1) {
termInput.value = prefix + items[0] + (items[0].startsWith('"') ? '' : ' '); termInput.value = prefix + items[0] + (items[0].startsWith('"') ? '' : ' ');
@@ -2551,7 +2790,8 @@ Write the corrected code.`;
// Klikkaa terminaalipaneelia → fokusoi input // Klikkaa terminaalipaneelia → fokusoi input
termPanel?.addEventListener('click', () => termInput?.focus()); termPanel?.addEventListener('click', () => termInput?.focus());
uiSocket.onmessage = (event) => { // Tallennetaan message-handler funktioon jotta reconnect voi käyttää samaa
const _wsHandler = (event) => {
try { try {
const raw = event.data; const raw = event.data;
if (raw.includes('"single_tokenize"')) return; if (raw.includes('"single_tokenize"')) return;
@@ -2913,6 +3153,8 @@ Write the corrected code.`;
} }
} catch(e) {} } catch(e) {}
}; };
window._wsMessageHandler = _wsHandler;
if (uiSocket) uiSocket.onmessage = _wsHandler;
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
// Käytetään viewer-authissa jo tunnistettua WebGPU-tilaa // Käytetään viewer-authissa jo tunnistettua WebGPU-tilaa