31 Commits

Author SHA1 Message Date
afc7f9bcee Versio 0.2.4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:52:21 +03:00
5d2027b2ca Native-node: automaattinen Ollama-haistelu käynnistyksessä
Jos OLLAMA_URL ei ole asetettu, kokeillaan järjestyksessä:
1. localhost:11434 (paikallinen Ollama)
2. 127.0.0.1:11434
3. ollama:11434 (Docker-verkko)
4. host.docker.internal:11434 (Docker-kontti → isäntä)

Ensimmäinen joka vastaa /api/version-kutsuun valitaan.
Timeout 2s per kokeilu. Jos OLLAMA_URL on asetettu, sitä käytetään suoraan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:41:44 +03:00
8a4d515eed Client-compose: Ollama lisätty jokaiseen profiiliin (nvidia/amd/cpu)
Ollama-palvelu puuttui client-composesta — native-node yritti yhdistää
ollamaan jota ei ollut. Nyt jokaisessa profiilissa on oma Ollama
(nvidia: latest+GPU, amd: rocm+/dev/kfd, cpu: latest) network alias
'ollama' jotta native-node löytää sen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:40:33 +03:00
987a370a05 Docker: Cargo.lock valinnainen, Ollama AMD ROCm -tuella
- Dockerfile.native-node: Cargo.lock kopioidaan glob-patternilla (ei kaadu
  jos puuttuu, esim. .gitignore poistaa sen)
- docker-compose: Ollama vaihdettu ollama/ollama:rocm -imageen AMD GPU:lle,
  /dev/kfd + /dev/dri laitemappaukset, poistettu nvidia deploy.resources

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:33:27 +03:00
f75e7f07e9 Opas: tokenisointiesimerkki korvattu oikealla kuvakaappauksella
- Staattinen tekstitokenisointiesimerkki korvattu kuvalla joka
  näyttää värikoodatut tokenit EN/FI-vertailussa
- Markdown-renderöijään lisätty ![alt](src) kuvatuki
- Kuva: static/images/tokenization-example.png

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:20:50 +03:00
eb6f720fcc Wasm tokenize_js() exportti oppaan live-tokenizeria varten
Lisätty #[wasm_bindgen] tokenize_js(text) → JSON-funktio joka lataa
tokenizerin IndexedDB:stä tai HuggingFacesta tarvittaessa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:18:07 +03:00
e25d0ea8f2 Versio 0.2.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:15:05 +03:00
4d1e89da34 Avatar-kuvakkeet 48px → 50px (+5%)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:14:14 +03:00
bf535b6256 Raportti: syntaksikorostus, CSS-tooltipsit rivityksellä, parannettu layout
- Lisätty highlightCode() — regex-pohjainen korostus .py, .html, Dockerfile
- Swimlane-badget: title-attr → CSS ::after tooltip (white-space:pre-wrap)
- Tiedostokortit: kieli + rivimäärä headeriin
- Yleinen ilme: pyöristetyt kulmat, monospace-fontti, parempi line-height
- Pipeline-vaiheet: selkeämmät erottimet ja väripaletti

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:10:37 +03:00
29e1c440c6 Native-node osoittaa nyt tuotantohubiin (kipina.studio)
HUB_URL vaihdettu ws://agentic-poc:3000/ws → wss://kipina.studio/ws
jotta lokaali GPU-solmu palvelee tuotantoympäristöä.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:05:19 +03:00
560bee1369 Laskentaverkko ja Koodilaboratorio -tabit takaisin näkyviin
Tabit olivat piilotettu display:none-tyylillä, mikä rikkoi myös
hash-navigoinnin (#codelab, #network).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:01:21 +03:00
b074e0cb49 Avatar-korttien opacity nostettu 0.5 → 0.8
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:55:46 +03:00
9307c75516 Pysty/vaaka-toggle avatareille
Lisätty nappi jolla org-chartin voi vaihtaa vaaka- ja pystyasennon välillä.
Vaaka on oletuksena (kompakti), pysty lisää vertical-CSS-luokan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:55:07 +03:00
86191fbb6c Avatarit vaakariviin: kompakti layout, säästää pystytilaa
Org-chart muutettu vertikaalisesta hierarkiasta horisontaaliseksi riviksi:
Asiakas → Manageri → [Koodari, Data, QA, DevOps, Tarkkailija]
- Connector-viivat muutettu pystysuuntaisista vaakanuoliksi
- Avatar-kortit 72px leveitä (oli 130px), kuvat 48px (oli 80px)
- Roolikuvaus poistettu korteista — pelkkä nimi riittää
- Tarkkailija siirretty absolute-asemasta rivin loppuun
- Responsive-tyylit päivitetty

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:53:25 +03:00
a6a94f7688 Avatar-logiikka: poistettu kilpaileva llm_prompt-handler, korjattu vilkkumisjärjestys
Ongelma: kaksi erillistä avatar-aktivointilogiikkaa kilpaili:
1) pipelineStep() — oikea agentti statuksen perusteella
2) llm_prompt-handler — arvasi agentin prompt-tekstistä (usein väärin → DevOps vilkkui QA:n/Datan sijaan)

Korjaus:
- Poistettu llm_prompt- ja llm_done-handlerien avatar-heuristiikat
- pipelineStep() hoitaa kaiken: active → syttyy, done → sammuu heti
- Pipeline-lopussa kaikki avataret sammutetaan eksplisiittisesti

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:42:02 +03:00
8d5c5440d2 Avatar-vilahdus: oikea agentti aktivoituu vuorollaan, manageri-bugi korjattu
pipelineStep() aktivoi nyt oikean agentin avatarin (sekä card että gallery-head)
kun status on 'active', ja poistaa 'done'-statuksella sekunnin viiveellä.
Poistettu llm_done-handlerin turha manageri-aktivointi joka vilkutti aina manageria.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:38:08 +03:00
a12bd7ce7f One-liner koodi: system prompt vaatii rivinvaihdot + staattinen tarkistus
Ollaman system prompt: 'Use proper newlines and indentation'.
Staattinen analyysi: havaitsee jos .py-tiedosto on yhdellä rivillä.
Native node vaatii rebuildin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:07:51 +03:00
9ac90aa540 pyproject.toml esimerkki: lisätty httpx ja pytest riippuvuuksiin
TestClient vaatii httpx:n, testit vaativat pyTestin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:01:35 +03:00
32065d5818 Korjattu </script> index.html-esimerkissä joka katkaisi pääsivun JS:n
Sama bugi kuin aiemmin: template-literalin </script> sulkee
ulomman script-tagin. Pilkottu: '<'+'/script>'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:00:53 +03:00
321943ee3c Esimerkkiprojektit: täysi CRUD + HTML UI root-osoitteessa
Few-shot esimerkit päivitetty:
- main.py: GET/POST/PUT/DELETE + FileResponse("/") index.html:lle
- index.html: yksinkertainen UI fetch()-kutsuilla API:in
- /api/ -prefiksi JSON-endpointeille
- Esimerkkipromptit kuvaavat CRUD-operaatiot eksplisiittisesti

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:49:38 +03:00
1b75c89320 Raportti: Mermaid → custom swimlane (kompakti, tooltipsit)
Agenteittain ryhmitelty visualisointi:
  Manageri  ✓ Suunnittelu
  Koodari   ✓ models.py → ✓ main.py → ✓ index.html
  DevOps    ✓ Review → ✓ Dockerfile → ✓ Compose → ✓ README
  QA        ✓ Testit → ✓ Validointi
Hover näyttää selityksen + output-esikatselun.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:39:23 +03:00
01622a960f Mermaid-kaavio LR (vaakasuunta) + tooltipsit joka vaiheelle
graph TD → graph LR: kaavio kiemurtelee vasemmalta oikealle.
Hover näyttää vaiheen selityksen ja output-esikatselun.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:33:23 +03:00
4e4efda67d Korjattu </script> template-literalissa joka katkaisi pääsivun JS:n
Template-stringin sisällä oleva </script> sulki ulomman script-tagin.
Pilkottu string-konkatenoimalla: '</'+' script>'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:25:36 +03:00
f5db2eb034 Esimerkkiprojektit HTML UI:lla + Mermaid-kaavio raporttiin + tooltips
- Esimerkkipromptit sisältävät nyt HTML-käyttöliittymän
- Manageri generoi index.html tiedoston, Dockerfile kopioi sen
- README: docker compose up → http://localhost:8000
- Raporttiin Mermaid-kaavio agenttien workflowsta (CDN)
- Pipeline-vaiheiden hover näyttää selityksen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:06:15 +03:00
77c8d46e7b Staattinen analyysi: tiedostojen väliset importit tarkistetaan
from db import get_db → tarkistaa onko get_db määritelty db.py:ssä
(def, class tai muuttuja). Löytää puuttuvat exportit ennen Docker-buildia.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:34:41 +03:00
f14eba1b49 Projektiraportti: HTML-dokumentaatio pipeline-vaiheista ja tiedostoista
Pipeline generoi HTML-raportin joka sisältää:
- Pipeline-vaiheet prompteineen ja tuloksineen (avattavat details)
- Staattisen analyysin tulokset
- Kaikki generoidut tiedostot korostettuina
- Raportti avautuu uuteen välilehteen linkistä terminaalissa
- Projektikorttiin lisätty 📄 Raportti -nappi

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:22:03 +03:00
6d15298418 Parannettu staattinen analyysi + docker-compose template
Staattinen analyysi: tarkistaa 15 yleistä symbolia (Boolean, Text,
Depends, HTTPException jne.) puuttuvista importeista.
Models-esimerkki: lisätty Boolean ja Text importteihin.
docker-compose.yml: template ilman LLM:ää — ei enää version tai
turhaa PostgreSQL-containeria SQLite-projektissa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:16:19 +03:00
cea1961183 Testaaja: staattinen analyysi + strukturoitu LLM-arviointi
Staattinen analyysi selaimessa (ennen LLM:ää):
- Käyttämättömät importit
- Puuttuvat importit (FastAPI, Session)
- Tyhjät funktiot

LLM-arviointi 5 kohdalla (esimerkkivastauksineen):
1. Imports, 2. Database, 3. Endpoints, 4. Error handling, 5. Security

Korjausluuppi käynnistyy jos ✗ löytyy tai staattinen analyysi huomauttaa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:13:15 +03:00
21a8015ea3 SQLAlchemy esimerkki: declarative_base() → DeclarativeBase (v2.0+)
declarative_base() on poistettu SQLAlchemy 2.0:sta.
Päivitetty molemmat esimerkit käyttämään class Base(DeclarativeBase).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:07:23 +03:00
c3991193d9 Pipeline-vaiheet rivittyvät + fixedDockerfile viittaus korjattu
Poistettu fixedDockerfile-viittaus joka kaatoi pipelinen ennen
renderProjectCard:ia → ZIP ei generoitunut.
Pipeline-vaiheet käyttävät nyt flex-wrap:ia eikä overflow-x:ää.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:03:14 +03:00
02c6d67218 Korjausvaihe ei ylikirjoita Dockerfilea — template pysyy
QA-validoinnin korjausvaihe antoi LLM:n generoida uuden Dockerfilen
joka sekoitti pip:n ja uv:n. Nyt korjaus kohdistuu vain .py ja
pyproject.toml -tiedostoihin. Dockerfile pysyy templatena.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:58:49 +03:00
10 changed files with 604 additions and 206 deletions

View File

@@ -5,7 +5,8 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml ./
COPY Cargo.loc[k] ./
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

View File

@@ -1,13 +1,12 @@
services: services:
# NVIDIA GPU -solmu # Ollama NVIDIA GPU:lla
native-node-nvidia: ollama-nvidia:
build: image: ollama/ollama:latest
context: . container_name: kipina-ollama
dockerfile: Dockerfile.native-node ports:
container_name: kipina-node-nvidia - "11434:11434"
environment: volumes:
- HUB_URL=wss://kipina.studio/ws - ollama-models:/root/.ollama
- ALLOCATED_GB=4
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
@@ -16,6 +15,65 @@ services:
- driver: nvidia - driver: nvidia
count: all count: all
capabilities: [gpu] capabilities: [gpu]
networks:
default:
aliases:
- ollama
profiles:
- nvidia
# Ollama AMD ROCm GPU:lla
ollama-amd:
image: ollama/ollama:rocm
container_name: kipina-ollama
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
restart: unless-stopped
devices:
- /dev/kfd:/dev/kfd
- /dev/dri:/dev/dri
group_add:
- video
- render
networks:
default:
aliases:
- ollama
profiles:
- amd
# Ollama CPU:lla
ollama-cpu:
image: ollama/ollama:latest
container_name: kipina-ollama
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
restart: unless-stopped
networks:
default:
aliases:
- ollama
profiles:
- cpu
# NVIDIA GPU -solmu
native-node-nvidia:
build:
context: .
dockerfile: Dockerfile.native-node
container_name: kipina-node-nvidia
environment:
- HUB_URL=wss://kipina.studio/ws
- OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=qwen2.5-coder:7b
- ALLOCATED_GB=4
restart: unless-stopped
depends_on:
- ollama-nvidia
profiles: profiles:
- nvidia - nvidia
@@ -27,14 +85,12 @@ services:
container_name: kipina-node-amd container_name: kipina-node-amd
environment: environment:
- HUB_URL=wss://kipina.studio/ws - HUB_URL=wss://kipina.studio/ws
- OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=qwen2.5-coder:7b
- ALLOCATED_GB=4 - ALLOCATED_GB=4
restart: unless-stopped restart: unless-stopped
devices: depends_on:
- /dev/kfd:/dev/kfd - ollama-amd
- /dev/dri:/dev/dri
group_add:
- video
- render
profiles: profiles:
- amd - amd
@@ -46,7 +102,14 @@ services:
container_name: kipina-node-cpu container_name: kipina-node-cpu
environment: environment:
- HUB_URL=wss://kipina.studio/ws - HUB_URL=wss://kipina.studio/ws
- OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=qwen2.5-coder:7b
- ALLOCATED_GB=2 - ALLOCATED_GB=2
restart: unless-stopped restart: unless-stopped
depends_on:
- ollama-cpu
profiles: profiles:
- cpu - cpu
volumes:
ollama-models:

View File

@@ -11,21 +11,19 @@ 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"
# Ollama — LLM-inferenssi GPU:lla (NVIDIA/AMD/Apple) # Ollama — LLM-inferenssi
# NVIDIA: vaihda image → ollama/ollama:latest ja lisää deploy.resources (ks. README)
# CPU: vaihda image → ollama/ollama:latest ja poista devices
ollama: ollama:
image: ollama/ollama:latest image: ollama/ollama:rocm
container_name: kipina_ollama container_name: kipina_ollama
ports: ports:
- "11434:11434" - "11434:11434"
volumes: volumes:
- ollama-models:/root/.ollama - ollama-models:/root/.ollama
deploy: devices:
resources: - /dev/kfd
reservations: - /dev/dri
devices:
- driver: nvidia
count: all
capabilities: [gpu]
profiles: profiles:
- native - native
@@ -36,12 +34,11 @@ services:
dockerfile: Dockerfile.native-node dockerfile: Dockerfile.native-node
container_name: kipina_native_node container_name: kipina_native_node
environment: environment:
- HUB_URL=ws://agentic-poc:3000/ws - HUB_URL=wss://kipina.studio/ws
- OLLAMA_URL=http://ollama:11434 - OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=qwen2.5-coder:7b - OLLAMA_MODEL=qwen2.5-coder:7b
- ALLOCATED_GB=4 - ALLOCATED_GB=4
depends_on: depends_on:
- agentic-poc
- ollama - ollama
profiles: profiles:
- native - native

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "hub" name = "hub"
version = "0.2.2" version = "0.2.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -8,17 +8,47 @@ pub struct LlmEngine {
} }
impl LlmEngine { impl LlmEngine {
pub fn load() -> Result<Self, String> { pub async fn load() -> Result<Self, String> {
let ollama_url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "qwen2.5-coder:7b".to_string()); let model = std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "qwen2.5-coder:7b".to_string());
tracing::info!("Ollama backend: {} | malli: {}", ollama_url, model);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(600)) .timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(3))
.build() .build()
.map_err(|e| format!("HTTP client: {}", e))?; .map_err(|e| format!("HTTP client: {}", e))?;
// Jos OLLAMA_URL on asetettu, käytetään sitä suoraan
let ollama_url = if let Ok(url) = std::env::var("OLLAMA_URL") {
tracing::info!("Ollama backend (env): {}", url);
url
} else {
// Haistellaan Ollamaa tunnetuista osoitteista
let candidates = [
"http://localhost:11434",
"http://127.0.0.1:11434",
"http://ollama:11434",
"http://host.docker.internal:11434",
];
let mut found = None;
for url in &candidates {
let probe = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(2))
.build().unwrap_or(client.clone());
if let Ok(resp) = probe.get(format!("{}/api/version", url)).send().await {
if resp.status().is_success() {
tracing::info!("Ollama löytyi osoitteesta: {}", url);
found = Some(url.to_string());
break;
}
}
}
found.unwrap_or_else(|| {
tracing::warn!("Ollamaa ei löytynyt — käytetään oletusta http://localhost:11434");
"http://localhost:11434".to_string()
})
};
tracing::info!("Ollama backend: {} | malli: {}", ollama_url, model);
Ok(LlmEngine { ollama_url, model: RefCell::new(model), client }) Ok(LlmEngine { ollama_url, model: RefCell::new(model), client })
} }
@@ -49,7 +79,7 @@ impl LlmEngine {
} }
pub async fn generate(&self, prompt: &str, max_tokens: usize) -> Result<GenerateResult, String> { 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 system = "You are a coding assistant. Respond with ONLY code. Use proper newlines and indentation. No explanations, no markdown fences, no comments unless asked.";
let model = self.model.borrow().clone(); let model = self.model.borrow().clone();
let start = Instant::now(); let start = Instant::now();

View File

@@ -287,7 +287,7 @@ async fn main() {
// Ollama-backend // Ollama-backend
tracing::info!("Alustetaan Ollama-yhteyttä..."); tracing::info!("Alustetaan Ollama-yhteyttä...");
let llm = match inference::LlmEngine::load() { let llm = match inference::LlmEngine::load().await {
Ok(engine) => { Ok(engine) => {
// Varmistetaan malli (ollama pull) — odotetaan kunnes valmis // Varmistetaan malli (ollama pull) — odotetaan kunnes valmis
match engine.ensure_model().await { match engine.ensure_model().await {

View File

@@ -118,6 +118,27 @@ async fn run_ai_tensor_inference(difficulty: usize) -> String {
format!("PoC {} Matmul ({}x{}) >> {}", backend_name, active_workload_size, active_workload_size, result) format!("PoC {} Matmul ({}x{}) >> {}", backend_name, active_workload_size, active_workload_size, result)
} }
/// JS-exportti: tokenisoi tekstin ja palauttaa JSON-merkkijonon
/// Tokenizer ladataan IndexedDB:stä (täytyy olla ladattu aiemmin)
#[wasm_bindgen]
pub async fn tokenize_js(text: String) -> Result<String, JsValue> {
let cached_tok = storage::load_from_idb("tokenizer.json").await.unwrap_or(None);
let Some(bytes) = cached_tok else {
// Yritetään ladata verkosta
let resp = reqwest::get("https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B/resolve/main/tokenizer.json").await
.map_err(|e| JsValue::from_str(&format!("Tokenizer-lataus epäonnistui: {}", e)))?;
let bytes = resp.bytes().await
.map_err(|e| JsValue::from_str(&format!("Tokenizer-lataus epäonnistui: {}", e)))?;
let _ = storage::save_to_idb("tokenizer.json", &bytes).await;
let tokenizer = tokenizers::Tokenizer::from_bytes(&bytes)
.map_err(|e| JsValue::from_str(&format!("Tokenizer-parsinta: {}", e)))?;
return Ok(tokenize_text(&tokenizer, &text).to_string());
};
let tokenizer = tokenizers::Tokenizer::from_bytes(&bytes)
.map_err(|e| JsValue::from_str(&format!("Tokenizer-parsinta: {}", e)))?;
Ok(tokenize_text(&tokenizer, &text).to_string())
}
/// Tokenisoi yhden tekstin ja palauttaa metriikat /// Tokenisoi yhden tekstin ja palauttaa metriikat
fn tokenize_text(tokenizer: &tokenizers::Tokenizer, text: &str) -> serde_json::Value { fn tokenize_text(tokenizer: &tokenizers::Tokenizer, text: &str) -> serde_json::Value {
let char_count = text.chars().count(); let char_count = text.chars().count();

View File

@@ -36,29 +36,13 @@ sanan osa, kokonainen sana tai välilyönti. Tokenisaatio tehdään
BPE-algoritmilla (Byte Pair Encoding) joka oppii yleisimmät BPE-algoritmilla (Byte Pair Encoding) joka oppii yleisimmät
merkkijonot harjoitusdatasta. merkkijonot harjoitusdatasta.
### Esimerkki: koodi
```
"print('Hello')" → [print] [(' ] [Hello] [')] = 4 tokenia
"tulosta('Hei')" → [tul] [osta] [(' ] [He] [i] [')] = 6 tokenia
```
Koodi tokenisoidaan tehokkaasti koska `print`, `def`, `return` yms.
ovat kokonaisia tokeneita. Suomenkielinen `tulosta` joudutaan pilkkomaan
osiin koska se ei esiinny harjoitusdatassa kokonaisena.
### Esimerkki: suomi vs. englanti ### Esimerkki: suomi vs. englanti
Sama lause kahdella kielellä Qwen2.5-Coder -tokenisaattorilla: Alla oikea tokenisointitulos Qwen2.5-Coder-tokenisaattorilla. Jokainen
värikoodattu lohko on yksi tokeni — huomaa miten suomi vaatii enemmän
tokeneita saman merkityksen välittämiseen:
| | Teksti | Tokenit | Määrä | Merkkejä/token | ![Tokenisointivertailu EN/FI](/images/tokenization-example.png)
|---|---|---|---|---|
| EN | The cat sat on the mat | [The] [ cat] [ sat] [ on] [ the] [ mat] | **6** | 3.7 |
| FI | Kissa istui matolla | [K] [issa] [ ist] [ui] [ mat] [olla] | **6** | 3.2 |
| EN | Distributed computing in the browser | [Dist] [ributed] [ computing] [ in] [ the] [ browser] | **6** | 6.0 |
| FI | Hajautettu laskenta selaimessa | [H] [aj] [au] [tettu] [ las] [kenta] [ sel] [aim] [essa] | **9** | 3.3 |
| EN | Write a function that sorts a list | [Write] [ a] [ function] [ that] [ sorts] [ a] [ list] | **7** | 5.0 |
| FI | Kirjoita funktio joka lajittelee listan | [K] [irj] [oita] [ funkt] [io] [ joka] [ laj] [ittel] [ee] [ listan] | **10** | 4.0 |
**Huomaa miten:** **Huomaa miten:**
- Englannin yleiset sanat (`the`, `in`, `a`, `function`) ovat kokonaisia tokeneita - Englannin yleiset sanat (`the`, `in`, `a`, `function`) ovat kokonaisia tokeneita

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -397,56 +397,58 @@
.terminal-prompt { color: #d29922; } .terminal-prompt { color: #d29922; }
.org-chart { .org-chart {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
margin-bottom: 40px; gap: 6px;
perspective: 1000px; margin-bottom: 12px;
padding: 25px 50px; padding: 10px 12px;
overflow-x: auto;
} }
.org-level { .org-level {
display: flex; display: flex;
justify-content: center; gap: 6px;
gap: 40px;
position: relative; position: relative;
z-index: 2; z-index: 2;
flex-shrink: 0;
} }
.org-connector { .org-connector {
width: 2px; width: 20px;
height: 40px; height: 2px;
background: linear-gradient(to bottom, rgba(88, 166, 255, 0.8), rgba(88, 166, 255, 0.2)); background: linear-gradient(to right, rgba(88, 166, 255, 0.8), rgba(88, 166, 255, 0.2));
margin: 0px auto; align-self: center;
box-shadow: 0 0 10px rgba(88, 166, 255, 0.5); flex-shrink: 0;
} }
.org-branch { .org-branch {
width: 510px; display: none;
height: 40px; }
border-top: 2px solid rgba(88, 166, 255, 0.5); .org-chart.vertical {
border-left: 2px solid rgba(88, 166, 255, 0.5); flex-direction: column;
border-right: 2px solid rgba(88, 166, 255, 0.5); align-items: center;
border-top-left-radius: 12px; gap: 0;
border-top-right-radius: 12px; }
margin-top: 0; .org-chart.vertical .org-connector {
margin-bottom: -2px; width: 2px;
box-shadow: inset 0 3px 6px -3px rgba(88, 166, 255, 0.4); height: 24px;
background: linear-gradient(to bottom, rgba(88, 166, 255, 0.8), rgba(88, 166, 255, 0.2));
} }
.avatar-card { .avatar-card {
background: linear-gradient(145deg, rgba(33, 38, 45, 0.4) 0%, rgba(13, 17, 23, 0.8) 100%); background: linear-gradient(145deg, rgba(33, 38, 45, 0.4) 0%, rgba(13, 17, 23, 0.8) 100%);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(240, 246, 252, 0.1); border: 1px solid rgba(240, 246, 252, 0.1);
border-radius: 16px; border-radius: 12px;
padding: 12px 10px; padding: 6px 6px 4px;
text-align: center; text-align: center;
width: 130px; width: 72px;
opacity: 0.5; opacity: 0.8;
cursor: pointer; cursor: pointer;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 8px 16px rgba(0,0,0,0.3); box-shadow: 0 4px 8px rgba(0,0,0,0.3);
} }
.avatar-card:hover { .avatar-card:hover {
opacity: 0.85; opacity: 0.85;
transform: translateY(-4px) scale(1.02); transform: translateY(-2px) scale(1.02);
border-color: rgba(240, 246, 252, 0.3); border-color: rgba(240, 246, 252, 0.3);
box-shadow: 0 12px 20px rgba(0,0,0,0.4); box-shadow: 0 8px 14px rgba(0,0,0,0.4);
} }
@keyframes idle-breathe { @keyframes idle-breathe {
0%, 100% { transform: translateY(0) scale(1); } 0%, 100% { transform: translateY(0) scale(1); }
@@ -461,10 +463,10 @@
} }
.avatar-card img { .avatar-card img {
width: 80px; width: 50px;
height: 80px; height: 50px;
border-radius: 18px; border-radius: 12px;
margin-bottom: 8px; margin-bottom: 4px;
border: 2px solid rgba(240, 246, 252, 0.1); border: 2px solid rgba(240, 246, 252, 0.1);
transition: all 0.4s ease; transition: all 0.4s ease;
object-fit: cover; object-fit: cover;
@@ -608,7 +610,7 @@
background: #0d1117; background: #0d1117;
border: 1px solid var(--accent-color); border: 1px solid var(--accent-color);
} }
.avatar-name { font-weight: 700; font-size: 13px; color: #f0f6fc; letter-spacing: 0.5px; margin-bottom: 2px; } .avatar-name { font-weight: 700; font-size: 10px; color: #f0f6fc; letter-spacing: 0.3px; margin-bottom: 0; }
.avatar-role { font-size: 10px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; line-height: 1.2; word-wrap: break-word; } .avatar-role { font-size: 10px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; line-height: 1.2; word-wrap: break-word; }
.agent-prompt-editor { .agent-prompt-editor {
margin-top: 12px; margin-top: 12px;
@@ -669,17 +671,14 @@
#metrics-grid { grid-template-columns: 1fr 1fr !important; } #metrics-grid { grid-template-columns: 1fr 1fr !important; }
/* Org chart mobile tweaks */ /* Org chart mobile tweaks */
.org-chart { padding: 20px 10px; } .org-chart { padding: 6px; gap: 4px; }
.org-branch { display: none; } .org-level { flex-wrap: wrap; gap: 4px !important; }
.org-connector { margin-bottom: 10px; height: 20px; } .org-connector { width: 12px; }
.org-level { flex-wrap: wrap; justify-content: center; gap: 15px !important; }
#avatar-observer { display: block; position: relative !important; right: auto !important; top: auto !important; margin: 0 auto; margin-bottom: 15px; }
/* Avatar cards downscaling */ /* Avatar cards downscaling */
.avatar-card { width: 100px; padding: 8px 4px; } .avatar-card { width: 56px; padding: 4px 3px 2px; }
.avatar-card img { width: 55px; height: 55px; margin-bottom: 4px; border-radius: 12px; } .avatar-card img { width: 36px; height: 36px; margin-bottom: 2px; border-radius: 8px; }
.avatar-name { font-size: 11px; margin-bottom: 1px; } .avatar-name { font-size: 9px; margin-bottom: 0; }
.avatar-role { font-size: 8px; line-height: 1.1; }
/* User Input Area */ /* User Input Area */
#user-input-box > div { flex-direction: column; } #user-input-box > div { flex-direction: column; }
@@ -696,8 +695,8 @@
.container > div:first-child { margin-bottom: 6px; } .container > div:first-child { margin-bottom: 6px; }
.container h1 { font-size: 22px; } .container h1 { font-size: 22px; }
.container .sub { font-size: 12px; } .container .sub { font-size: 12px; }
.avatar-card { padding: 6px 4px; } .avatar-card { padding: 4px 4px 2px; }
.avatar-card img { width: 50px; height: 50px; margin-bottom: 4px; } .avatar-card img { width: 40px; height: 40px; margin-bottom: 2px; }
} }
@media (min-height: 1200px) { @media (min-height: 1200px) {
.terminal-panel { height: clamp(350px, 40vh, 600px); } .terminal-panel { height: clamp(350px, 40vh, 600px); }
@@ -722,8 +721,8 @@
<!-- Päävälilehdet --> <!-- Päävälilehdet -->
<div class="main-tabs"> <div class="main-tabs">
<!-- Laskentaverkko ja Koodilaboratorio piilotettu (koodi säilytetty) --> <!-- Laskentaverkko ja Koodilaboratorio piilotettu (koodi säilytetty) -->
<div class="main-tab" onclick="switchMainTab('network')" data-i18n="tab_network" style="display:none">Laskentaverkko</div> <div class="main-tab" onclick="switchMainTab('network')" data-i18n="tab_network">Laskentaverkko</div>
<div class="main-tab" onclick="switchMainTab('codelab')" data-i18n="tab_codelab" style="display:none">Koodilaboratorio</div> <div class="main-tab" onclick="switchMainTab('codelab')" data-i18n="tab_codelab">Koodilaboratorio</div>
<div class="main-tab active" onclick="switchMainTab('agents')" data-i18n="tab_agents">Kipinä Agentic Playground</div> <div class="main-tab active" onclick="switchMainTab('agents')" data-i18n="tab_agents">Kipinä Agentic Playground</div>
<div class="main-tab" onclick="switchMainTab('guide')" data-i18n="tab_guide">Opas</div> <div class="main-tab" onclick="switchMainTab('guide')" data-i18n="tab_guide">Opas</div>
</div> </div>
@@ -1018,6 +1017,7 @@
<div style="display:flex;align-items:center;gap:16px;"> <div style="display:flex;align-items:center;gap:16px;">
<span style="font-weight:600;font-size:15px;color:var(--text-color)"><span style="color:#ff6b00">Kipinä</span> Agent Workspace</span> <span style="font-weight:600;font-size:15px;color:var(--text-color)"><span style="color:#ff6b00">Kipinä</span> Agent Workspace</span>
<button id="btn-toggle-all" onclick="toggleAllAgents()" style="background:rgba(33, 38, 45, 0.8);border:1px solid var(--border-color);color:#c9d1d9;font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer;">Valitse kaikki</button> <button id="btn-toggle-all" onclick="toggleAllAgents()" style="background:rgba(33, 38, 45, 0.8);border:1px solid var(--border-color);color:#c9d1d9;font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer;">Valitse kaikki</button>
<button id="btn-toggle-layout" onclick="toggleOrgLayout()" style="background:rgba(33, 38, 45, 0.8);border:1px solid var(--border-color);color:#c9d1d9;font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer;">⇅ Pysty</button>
</div> </div>
<span id="agent-status" style="font-size:12px;color:var(--success-color)">Monitoring Active</span> <span id="agent-status" style="font-size:12px;color:var(--success-color)">Monitoring Active</span>
</div> </div>
@@ -1026,58 +1026,40 @@
<!-- LEFT COLUMN: Org chart & Prompt Editor --> <!-- LEFT COLUMN: Org chart & Prompt Editor -->
<div style="flex:1; min-width:300px; overflow-x:auto;"> <div style="flex:1; min-width:300px; overflow-x:auto;">
<div class="org-chart"> <div class="org-chart">
<!-- Taso 1 -->
<div class="org-level"> <div class="org-level">
<div class="avatar-card" id="avatar-client" data-agent="client" onclick="selectAgent('client', event)"> <div class="avatar-card" id="avatar-client" data-agent="client" onclick="selectAgent('client', event)">
<img src="/avatars/kettu_notext.png" alt="Asiakas (Kettu)"> <img src="/avatars/kettu_notext.png" alt="Asiakas">
<div class="avatar-name">Asiakas</div> <div class="avatar-name">Asiakas</div>
<div class="avatar-role">Tuoteomistaja</div>
</div> </div>
</div> </div>
<div class="org-connector"></div> <div class="org-connector"></div>
<div class="org-level">
<!-- Taso 2 -->
<div class="org-level" style="position: relative;">
<!-- Tarkkailija laitetaan erilleen kauemmas sivuun jotta se näyttää itsenäiseltä valvojalta -->
<div class="avatar-card" id="avatar-observer" data-agent="observer" onclick="selectAgent('observer', event)" style="position: absolute; right: calc(50% + 350px); top: 0;">
<img src="/avatars/aikuinen_susi.png" alt="Tarkkailija (Aikuinen Susi)">
<div class="avatar-name">Tarkkailija</div>
<div class="avatar-role">Laadunvalvonta</div>
</div>
<div class="avatar-card" id="avatar-kpn" data-agent="manager" onclick="selectAgent('manager', event)"> <div class="avatar-card" id="avatar-kpn" data-agent="manager" onclick="selectAgent('manager', event)">
<img src="/avatars/karhunpentu.png" alt="Manageri (Karhunpentu)"> <img src="/avatars/karhunpentu.png" alt="Manageri">
<div class="avatar-name">Manageri</div> <div class="avatar-name">Manageri</div>
<div class="avatar-role">KPN CLI</div>
</div> </div>
</div> </div>
<div class="org-connector"></div> <div class="org-connector"></div>
<div class="org-level">
<div class="org-branch"></div>
<!-- Taso 3 -->
<div class="org-level" style="gap: 20px;">
<div class="avatar-card" id="avatar-coder" data-agent="coder" onclick="selectAgent('coder', event)"> <div class="avatar-card" id="avatar-coder" data-agent="coder" onclick="selectAgent('coder', event)">
<img src="/avatars/kipina_notext.png" alt="Koodari (Salamanteri)"> <img src="/avatars/kipina_notext.png" alt="Koodari">
<div class="avatar-name">Koodari</div> <div class="avatar-name">Koodari</div>
<div class="avatar-role">SOFTAKEHITYS</div>
</div> </div>
<div class="avatar-card" id="avatar-data" data-agent="data" onclick="selectAgent('data', event)"> <div class="avatar-card" id="avatar-data" data-agent="data" onclick="selectAgent('data', event)">
<img src="/avatars/pesukarhu_notext.png" alt="Data-Agentti (Pesukarhu)"> <img src="/avatars/pesukarhu_notext.png" alt="Data">
<div class="avatar-name">Data</div> <div class="avatar-name">Data</div>
<div class="avatar-role">Tietokannat</div>
</div> </div>
<div class="avatar-card" id="avatar-qa" data-agent="qa" onclick="selectAgent('qa', event)"> <div class="avatar-card" id="avatar-qa" data-agent="qa" onclick="selectAgent('qa', event)">
<img src="/avatars/susi_notext.png" alt="QA (Pikkususi)"> <img src="/avatars/susi_notext.png" alt="QA">
<div class="avatar-name">QA</div> <div class="avatar-name">QA</div>
<div class="avatar-role">Testaus</div>
</div> </div>
<div class="avatar-card" id="avatar-tester" data-agent="tester" onclick="selectAgent('tester', event)"> <div class="avatar-card" id="avatar-tester" data-agent="tester" onclick="selectAgent('tester', event)">
<img src="/avatars/laiskiainen_notext.png" alt="DevOps (Laiskiainen)"> <img src="/avatars/laiskiainen_notext.png" alt="DevOps">
<div class="avatar-name">DevOps</div> <div class="avatar-name">DevOps</div>
<div class="avatar-role">Käyttöönotto</div> </div>
<div class="avatar-card" id="avatar-observer" data-agent="observer" onclick="selectAgent('observer', event)">
<img src="/avatars/aikuinen_susi.png" alt="Tarkkailija">
<div class="avatar-name">Tarkkailija</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1131,7 +1113,7 @@
<button id="agent-compute-btn" style="margin-left:4px;padding:2px 10px;border-radius:4px;border:1px solid #30363d;background:#161b22;color:#58a6ff;font-size:12px;font-family:inherit;cursor:pointer" title="Käynnistä kielimalli omalla koneellasi laskentaa varten">Alusta laskentasolmu</button> <button id="agent-compute-btn" style="margin-left:4px;padding:2px 10px;border-radius:4px;border:1px solid #30363d;background:#161b22;color:#58a6ff;font-size:12px;font-family:inherit;cursor:pointer" title="Käynnistä kielimalli omalla koneellasi laskentaa varten">Alusta laskentasolmu</button>
</span> </span>
</div> </div>
<div id="pipeline-steps" style="display:none;background:#0d1117;border:1px solid var(--border-color);border-top:none;padding:8px 14px;font-family:'Courier New',monospace;font-size:12px;overflow-x:auto;white-space:nowrap"></div> <div id="pipeline-steps" style="display:none;background:#0d1117;border:1px solid var(--border-color);border-top:none;padding:8px 14px;font-family:'Courier New',monospace;font-size:12px;line-height:1.8;flex-wrap:wrap;display:flex;gap:2px"></div>
<div class="terminal-panel" id="agent-terminal" style="margin-top:0;border-top:none;border-radius:0"> <div class="terminal-panel" id="agent-terminal" style="margin-top:0;border-top:none;border-radius:0">
</div> </div>
<div style="position:relative;display:flex;align-items:center;background:#010409;border:1px solid var(--border-color);border-top:none;border-radius:0 0 6px 6px;padding:8px 12px;font-family:'Courier New',monospace;font-size:14px"> <div style="position:relative;display:flex;align-items:center;background:#010409;border:1px solid var(--border-color);border-top:none;border-radius:0 0 6px 6px;padding:8px 12px;font-family:'Courier New',monospace;font-size:14px">
@@ -1332,6 +1314,16 @@
updatePromptEditor(); updatePromptEditor();
}; };
window.toggleOrgLayout = function() {
const chart = document.querySelector('.org-chart');
const btn = document.getElementById('btn-toggle-layout');
if (chart.classList.toggle('vertical')) {
btn.textContent = '⇄ Vaaka';
} else {
btn.textContent = '⇅ Pysty';
}
};
// Autosave prompti // Autosave prompti
document.getElementById('agent-prompt-text')?.addEventListener('input', (e) => { document.getElementById('agent-prompt-text')?.addEventListener('input', (e) => {
if (selectedAgents.size === 0) return; if (selectedAgents.size === 0) return;
@@ -1459,12 +1451,14 @@ filename.py: one-line description
CONSTRAINTS: the coder can only generate ~400 tokens per file CONSTRAINTS: the coder can only generate ~400 tokens per file
- Max 3 files (keep it minimal) - Max 3 files (keep it minimal)
- Each file must be SHORT: one clear responsibility, no boilerplate - Each file must be SHORT: one clear responsibility, no boilerplate
- Only .py and pyproject.toml files - Only .py, .html and pyproject.toml files
- If the project has a UI, include one index.html served by FastAPI at /
EXAMPLE: for "FastAPI todo app with SQLite" EXAMPLE: for "FastAPI todo app with SQLite"
pyproject.toml: project metadata and dependencies pyproject.toml: project metadata and dependencies
models.py: SQLAlchemy models and database setup models.py: SQLAlchemy models and database setup
main.py: FastAPI app with CRUD endpoints main.py: FastAPI app with CRUD endpoints and serves index.html at /
index.html: simple HTML UI with forms and fetch() to call the API
Project: (käyttäjän kuvaus)` }, Project: (käyttäjän kuvaus)` },
coder: { label: 'Koodaus', prompt: `Project: (managerin suunnitelma) coder: { label: 'Koodaus', prompt: `Project: (managerin suunnitelma)
@@ -1514,11 +1508,11 @@ IMPORTANT: Use uv for package management (uv sync, uv run)` },
data: { label: 'Data', prompt: `SQLAlchemy models and database setup. data: { label: 'Data', prompt: `SQLAlchemy models and database setup.
EXAMPLE: EXAMPLE:
from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_engine("sqlite:///app.db") engine = create_engine("sqlite:///app.db")
Base = declarative_base() class Base(DeclarativeBase): pass
IMPORTANT: Include get_db() dependency for FastAPI` }, IMPORTANT: Include get_db() dependency for FastAPI` },
}; };
@@ -2145,7 +2139,7 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
pipelineSteps.push(step); pipelineSteps.push(step);
} }
renderPipelineSteps(); renderPipelineSteps();
// Päivitetään agentin avatar tooltip // Päivitetään agentin avatar tooltip + vilahdus
const avatarMap = { manager: 'avatar-kpn', coder: 'avatar-coder', tester: 'avatar-tester', qa: 'avatar-qa', data: 'avatar-data' }; const avatarMap = { manager: 'avatar-kpn', coder: 'avatar-coder', tester: 'avatar-tester', qa: 'avatar-qa', data: 'avatar-data' };
const avatarId = avatarMap[agent]; const avatarId = avatarMap[agent];
if (avatarId) { if (avatarId) {
@@ -2153,6 +2147,19 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
if (el) { if (el) {
const truncOut = (output || '').substring(0, 200).replace(/\n/g, ' '); const truncOut = (output || '').substring(0, 200).replace(/\n/g, ' ');
el.title = `${label}\n${status === 'active' ? '⏳ Käsittelee...' : '✓ Valmis'}\n\nInput: ${(input || '').substring(0, 100)}...\nOutput: ${truncOut}...`; el.title = `${label}\n${status === 'active' ? '⏳ Käsittelee...' : '✓ Valmis'}\n\nInput: ${(input || '').substring(0, 100)}...\nOutput: ${truncOut}...`;
// Avatar-aktivointi: syttyy vuoron alussa, sammuu lopussa
if (status === 'active') {
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
el.classList.add('active');
document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('active'));
const gw = document.getElementById('wrap-' + agent);
if (gw) gw.classList.add('active');
} else if (status === 'done') {
el.classList.remove('active');
const gw = document.getElementById('wrap-' + agent);
if (gw) gw.classList.remove('active');
}
} }
} }
} }
@@ -2161,15 +2168,16 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
const container = document.getElementById('pipeline-steps'); const container = document.getElementById('pipeline-steps');
if (!container) return; if (!container) return;
if (pipelineSteps.length === 0) { container.style.display = 'none'; return; } if (pipelineSteps.length === 0) { container.style.display = 'none'; return; }
container.style.display = 'block'; container.style.display = 'flex';
container.innerHTML = pipelineSteps.map((s, i) => { container.innerHTML = pipelineSteps.map((s, i) => {
const colors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' }; const colors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };
const color = colors[s.agent] || '#8b949e'; const color = colors[s.agent] || '#8b949e';
const icon = s.status === 'done' ? '✓' : s.status === 'active' ? '◷' : '◯'; const icon = s.status === 'done' ? '✓' : s.status === 'active' ? '◷' : '◯';
const iconColor = s.status === 'done' ? '#3fb950' : s.status === 'active' ? '#d29922' : '#8b949e'; const iconColor = s.status === 'done' ? '#3fb950' : s.status === 'active' ? '#d29922' : '#8b949e';
const arrow = i < pipelineSteps.length - 1 ? ' <span style="color:#30363d">→</span> ' : ''; const arrow = i < pipelineSteps.length - 1 ? ' <span style="color:#30363d">→</span> ' : '';
// Tooltip: input/output esikatselu const stepDescs = { 'Suunnittelu': 'Pilkkoo tiedostoiksi', 'Review': 'Arvioi koodin laadun', 'Testit': 'Kirjoittaa testit', 'Dockerfile': 'Docker-image', 'Compose': 'Palvelumääritys', 'README': 'Dokumentaatio', 'Validointi': 'Tarkistaa yhteensopivuuden', 'Korjaukset': 'Korjaa löydetyt bugit' };
return `<span onclick="openPipelineStepModal(${i})" style="cursor:pointer;padding:2px 4px;border-radius:3px;transition:background 0.2s" onmouseenter="this.style.background='#21262d'" onmouseleave="this.style.background='transparent'"><span style="color:${iconColor}">${icon}</span> <span style="color:${color}">${esc(s.label)}</span></span>${arrow}`; const desc = stepDescs[s.label] || s.label;
return `<span onclick="openPipelineStepModal(${i})" title="${desc}" style="cursor:pointer;padding:2px 4px;border-radius:3px;transition:background 0.2s" onmouseenter="this.style.background='#21262d'" onmouseleave="this.style.background='transparent'"><span style="color:${iconColor}">${icon}</span> <span style="color:${color}">${esc(s.label)}</span></span>${arrow}`;
}).join(''); }).join('');
} }
@@ -2191,7 +2199,7 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
// Globaali storage projektikorttien tiedostoille (välttää JSON data-attribuuttien ongelmat) // Globaali storage projektikorttien tiedostoille (välttää JSON data-attribuuttien ongelmat)
const projectFiles = {}; const projectFiles = {};
function renderProjectCard(files, projectName) { function renderProjectCard(files, projectName, reportUrl) {
const fileEntries = Object.entries(files); const fileEntries = Object.entries(files);
if (fileEntries.length === 0) return; if (fileEntries.length === 0) return;
@@ -2219,6 +2227,7 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
<span style="display:flex;gap:6px"> <span style="display:flex;gap:6px">
<button onclick="copyAllFiles('${cardId}')" style="background:none;border:1px solid #30363d;color:#8b949e;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Kopioi kaikki tiedostot leikepöydälle">Kopioi kaikki</button> <button onclick="copyAllFiles('${cardId}')" style="background:none;border:1px solid #30363d;color:#8b949e;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Kopioi kaikki tiedostot leikepöydälle">Kopioi kaikki</button>
<button onclick="downloadZip('${cardId}')" style="background:none;border:1px solid #30363d;color:#58a6ff;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Lataa projekti ZIP-tiedostona">Lataa ZIP</button> <button onclick="downloadZip('${cardId}')" style="background:none;border:1px solid #30363d;color:#58a6ff;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Lataa projekti ZIP-tiedostona">Lataa ZIP</button>
${reportUrl ? `<a href="${reportUrl}" target="_blank" style="background:none;border:1px solid #a371f7;color:#a371f7;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer;text-decoration:none">📄 Raportti</a>` : ''}
</span> </span>
</div> </div>
<div style="display:flex;gap:2px;padding:6px 8px 0;background:#0d1117">${tabsHtml}</div> <div style="display:flex;gap:2px;padding:6px 8px 0;background:#0d1117">${tabsHtml}</div>
@@ -2363,14 +2372,16 @@ filename.py: one-line description
CONSTRAINTS — the coder can only generate ~400 tokens per file: CONSTRAINTS — the coder can only generate ~400 tokens per file:
- Max 3 files (keep it minimal) - Max 3 files (keep it minimal)
- Each file must be SHORT: one clear responsibility, no boilerplate - Each file must be SHORT: one clear responsibility, no boilerplate
- Only .py and pyproject.toml files - Only .py, .html and pyproject.toml files
- If the project has a UI, include one index.html served by FastAPI at /
- No directories, no paths, just filenames - No directories, no paths, just filenames
- List dependencies first, then main app - List dependencies first, then main app
EXAMPLE for "FastAPI todo app with SQLite": EXAMPLE for "FastAPI todo app with SQLite":
pyproject.toml: project metadata and dependencies pyproject.toml: project metadata and dependencies
models.py: SQLAlchemy models and database setup models.py: SQLAlchemy models and database setup
main.py: FastAPI app with CRUD endpoints main.py: FastAPI app with CRUD endpoints and serves index.html at /
index.html: simple HTML UI with forms and fetch() to call the API
Project: ${task}`; Project: ${task}`;
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`);
@@ -2433,7 +2444,7 @@ Project: ${task}`;
name = "projectname" name = "projectname"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["fastapi", "uvicorn", "sqlalchemy"] dependencies = ["fastapi", "uvicorn", "sqlalchemy", "httpx", "pytest"]
[project.scripts] [project.scripts]
start = "uvicorn main:app --reload" start = "uvicorn main:app --reload"
@@ -2444,31 +2455,81 @@ IMPORTANT: Only list pip-installable packages. NEVER include Python stdlib modul
} }
const coderExample = file.name.includes('main') || file.name.includes('app') const coderExample = file.name.includes('main') || file.name.includes('app')
? `\nEXAMPLE output for a main.py: ? `\nEXAMPLE output for a main.py (CRUD + HTML UI):
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models import get_db, User from models import get_db, Base, engine, User
Base.metadata.create_all(engine)
app = FastAPI() app = FastAPI()
@app.get("/users") @app.get("/")
def index():
return FileResponse("index.html")
@app.get("/api/users")
def list_users(db: Session = Depends(get_db)): def list_users(db: Session = Depends(get_db)):
return db.query(User).all() return db.query(User).all()
@app.post("/users") @app.post("/api/users")
def create_user(name: str, db: Session = Depends(get_db)): def create_user(name: str, db: Session = Depends(get_db)):
user = User(name=name) user = User(name=name)
db.add(user) db.add(user)
db.commit() db.commit()
return {"id": user.id, "name": user.name}` return {"id": user.id, "name": user.name}
@app.put("/api/users/{user_id}")
def update_user(user_id: int, name: str, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
if not user: raise HTTPException(404)
user.name = name
db.commit()
return {"id": user.id, "name": user.name}
@app.delete("/api/users/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
if not user: raise HTTPException(404)
db.delete(user)
db.commit()
return {"ok": True}`
: file.name.includes('index.html')
? `\nEXAMPLE output for index.html (simple CRUD UI):
<!DOCTYPE html>
<html><head><title>App</title></head>
<body>
<h1>Users</h1>
<input id="name" placeholder="Name"><button onclick="add()">Add</button>
<ul id="list"></ul>
<script>
async function load() {
const r = await fetch('/api/users');
const users = await r.json();
document.getElementById('list').innerHTML = users.map(u =>
'<li>' + u.name + ' <button onclick="del('+u.id+')">x</button></li>'
).join('');
}
async function add() {
const name = document.getElementById('name').value;
await fetch('/api/users?name='+name, {method:'POST'});
document.getElementById('name').value = '';
load();
}
async function del(id) {
await fetch('/api/users/'+id, {method:'DELETE'});
load();
}
load();
<` + `/script></body></html>`
: file.name.includes('model') : file.name.includes('model')
? `\nEXAMPLE output for a models.py: ? `\nEXAMPLE output for a models.py:
from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_engine("sqlite:///app.db") engine = create_engine("sqlite:///app.db")
SessionLocal = sessionmaker(bind=engine) SessionLocal = sessionmaker(bind=engine)
Base = declarative_base() class Base(DeclarativeBase): pass
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -2484,7 +2545,7 @@ def get_db():
: ''; : '';
const coderPrompt = `${context}Project: ${task} const coderPrompt = `${context}Project: ${task}
Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions}${coderExample} Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions}${coderExample}
IMPORTANT: Keep the code SHORT. Max ~50 lines. No comments, no docstrings. Write minimal, working code. Output ONLY code.`; IMPORTANT: Keep the code SHORT. Max ~60 lines. No comments, no docstrings. Write minimal, working code. Output ONLY code. Serve index.html at / using FileResponse. Use /api/ prefix for JSON endpoints.`;
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');
@@ -2499,17 +2560,103 @@ IMPORTANT: Keep the code SHORT. Max ~50 lines. No comments, no docstrings. Write
.map(([name, code]) => `--- ${name} ---\n${code}`) .map(([name, code]) => `--- ${name} ---\n${code}`)
.join('\n\n'); .join('\n\n');
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 2}] Testaaja</span> — arviointi`); // Staattinen analyysi ennen LLM-arviointia
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 2}] Testaaja</span> — staattinen analyysi + arviointi`);
pipelineStep('tester', 'Review', 'active', `${Object.keys(generatedFiles).length} tiedostoa`); pipelineStep('tester', 'Review', 'active', `${Object.keys(generatedFiles).length} tiedostoa`);
const reviewPrompt = `Review this project. List bugs or issues. Be brief.
If the code is correct, say "LGTM". // Yksinkertainen staattinen tarkistus selaimessa
const staticIssues = [];
for (const [name, code] of Object.entries(generatedFiles)) {
if (!name.endsWith('.py')) continue;
const lines = (code || '').split('\n');
// Tarkista importit
const imports = lines.filter(l => l.match(/^(from|import)\s/));
const usedNames = code.replace(/^(from|import)\s.*/gm, '');
for (const imp of imports) {
const match = imp.match(/import\s+(\w+)|from\s+\S+\s+import\s+(.+)/);
if (match) {
const names = (match[1] || match[2]).split(',').map(n => n.trim().split(' as ').pop().trim());
for (const n of names) {
if (n && !usedNames.includes(n) && n !== '*') {
staticIssues.push(`${name}: käyttämätön import '${n}'`);
}
}
}
}
// Tarkista puuttuvat importit
const importText = imports.join(' ');
const needsImport = [
['FastAPI', 'FastAPI'], ['Session', 'Session'], ['Depends', 'Depends'],
['HTTPException', 'HTTPException'], ['TestClient', 'TestClient'],
['Boolean', 'Boolean'], ['Text', 'Text'], ['Float', 'Float'], ['DateTime', 'DateTime'],
['Column', 'Column'], ['create_engine', 'create_engine'],
['DeclarativeBase', 'DeclarativeBase'], ['sessionmaker', 'sessionmaker'],
];
for (const [symbol, name_] of needsImport) {
// Tarkista käytetäänkö symbolia koodissa (ei import-riveillä)
const codeWithoutImports = lines.filter(l => !l.match(/^(from|import)\s/)).join('\n');
if (codeWithoutImports.includes(symbol) && !importText.includes(symbol)) {
staticIssues.push(`${name}: käyttää '${symbol}' mutta ei importtaa sitä`);
}
}
// Tarkista tyhjät funktiot
const emptyFuncs = code.match(/def \w+\([^)]*\):\s*\n\s*(pass|\.\.\.)/g);
if (emptyFuncs) staticIssues.push(`${name}: ${emptyFuncs.length} tyhjää funktiota`);
// Tarkista one-liner koodi (kaikki yhdellä rivillä)
if (lines.length <= 2 && code.length > 200) {
staticIssues.push(`${name}: koodi on yhdellä rivillä (${code.length} merkkiä) — generoi uudelleen`);
}
// Tarkista tiedostojen väliset importit (from db import get_db → onko get_db db.py:ssä?)
for (const imp of imports) {
const crossMatch = imp.match(/from\s+(\w+)\s+import\s+(.+)/);
if (crossMatch) {
const modName = crossMatch[1];
const importedNames = crossMatch[2].split(',').map(n => n.trim().split(' as ')[0].trim());
const targetFile = modName + '.py';
const targetCode = generatedFiles[targetFile];
if (targetCode) {
for (const sym of importedNames) {
// Tarkista onko symboli määritelty kohdetiedostossa (def, class, muuttuja)
const defined = targetCode.includes(`def ${sym}`) || targetCode.includes(`class ${sym}`) || targetCode.match(new RegExp(`^${sym}\\s*=`, 'm'));
if (!defined) {
staticIssues.push(`${name}: importtaa '${sym}' tiedostosta ${targetFile}, mutta sitä ei ole määritelty siellä`);
}
}
}
}
}
}
if (staticIssues.length > 0) {
termLog(` <span style="color:#d29922">Staattinen analyysi (${staticIssues.length} huomautusta):</span>`);
for (const issue of staticIssues) {
termLog(` <span style="color:#d29922">⚠</span> ${esc(issue)}`);
}
} else {
termLog(' <span style="color:#3fb950">Staattinen analyysi: ei huomautuksia</span>');
}
const reviewPrompt = `Review this project code. Check EVERY item and report result:
1. Imports: ✓/✗ — are all imports valid and available?
2. Database: ✓/✗ — is the DB setup correct (engine, session, models)?
3. Endpoints: ✓/✗ — do all routes have correct parameters and return types?
4. Error handling: ✓/✗ — are edge cases handled (404, validation)?
5. Security: ✓/✗ — any SQL injection, missing auth, or data exposure?
EXAMPLE output:
1. Imports: ✓ — all imports are valid
2. Database: ✗ — missing Base.metadata.create_all(engine) call
3. Endpoints: ✓ — GET/POST/DELETE routes are correct
4. Error handling: ✗ — no 404 when todo not found
5. Security: ✓ — using ORM, no raw SQL
${allCode}`; ${allCode}`;
const review = await kpnRun(agentPrompts.tester.model, reviewPrompt, false, 200); const review = await kpnRun(agentPrompts.tester.model, reviewPrompt, false, 300);
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
if (review && !review.toLowerCase().includes('lgtm') && !review.toLowerCase().includes('looks good')) { const hasIssues = review && (review.includes('') || staticIssues.length > 0);
if (hasIssues) {
termLog(`\n<span style="color:#d29922;font-weight:bold">[${fileList.length + 3}] Koodari</span> — korjaukset`); termLog(`\n<span style="color:#d29922;font-weight:bold">[${fileList.length + 3}] Koodari</span> — korjaukset`);
pipelineStep('coder', 'Korjaukset', 'active', review); pipelineStep('coder', 'Korjaukset', 'active', review);
const fixPrompt = `Fix the issues found in the review. const fixPrompt = `Fix the issues found in the review.
@@ -2563,7 +2710,7 @@ ${Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\
const mainFile = Object.keys(generatedFiles).find(f => f.includes('main') || f.includes('app')) || Object.keys(generatedFiles)[0]; const mainFile = Object.keys(generatedFiles).find(f => f.includes('main') || f.includes('app')) || Object.keys(generatedFiles)[0];
const hasPyproject = 'pyproject.toml' in generatedFiles; const hasPyproject = 'pyproject.toml' in generatedFiles;
const hasRequirements = 'requirements.txt' in generatedFiles; const hasRequirements = 'requirements.txt' in generatedFiles;
const pyFiles = Object.keys(generatedFiles).filter(f => f.endsWith('.py')); const codeFiles = Object.keys(generatedFiles).filter(f => f.endsWith('.py') || f.endsWith('.html'));
// Dockerfile-templatti: ei anneta mallin keksiä omaa // Dockerfile-templatti: ei anneta mallin keksiä omaa
let depLines; let depLines;
if (hasPyproject) { if (hasPyproject) {
@@ -2577,7 +2724,7 @@ ${Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app WORKDIR /app
${depLines} ${depLines}
COPY ${pyFiles.join(' ')} ./ COPY ${codeFiles.join(' ')} ./
EXPOSE 8000 EXPOSE 8000
CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]`; CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]`;
// Generoidaan Dockerfile suoraan templatesta, ei mallilla // Generoidaan Dockerfile suoraan templatesta, ei mallilla
@@ -2589,16 +2736,21 @@ CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0
const step7 = step6 + 1; const step7 = step6 + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step7}] DevOps</span> — docker-compose.yml`); termLog(`\n<span style="color:#d29922;font-weight:bold">[${step7}] DevOps</span> — docker-compose.yml`);
pipelineStep('tester', 'Compose', 'active', 'docker-compose.yml'); pipelineStep('tester', 'Compose', 'active', 'docker-compose.yml');
const composePrompt = `Write a docker-compose.yml for this project. Include: // docker-compose.yml templatesta (ei LLM:llä — vältetään version/postgres ongelmat)
- app service (build from Dockerfile, port mapping, restart: unless-stopped) const composeContent = `services:
- db service if SQLite/PostgreSQL is used (volume for data persistence) app:
- Named volumes for persistent data build: .
Only output the YAML content, nothing else. ports:
- "8000:8000"
volumes:
- app-data:/app/data
restart: unless-stopped
Files: ${Object.keys(generatedFiles).join(', ')}`; volumes:
const compose = await kpnRun(agentPrompts.tester.model, composePrompt, false, 256); app-data:`;
if (compose) generatedFiles['docker-compose.yml'] = compose; generatedFiles['docker-compose.yml'] = composeContent;
pipelineStep('tester', 'Compose', 'done', 'docker-compose.yml', compose); termLog(` <span style="color:#3fb950">✓</span> docker-compose.yml generoitu (template)`);
pipelineStep('tester', 'Compose', 'done', composeContent, composeContent);
// Vaihe 8: DevOps — README // Vaihe 8: DevOps — README
const step8 = step7 + 1; const step8 = step7 + 1;
@@ -2606,8 +2758,8 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
pipelineStep('tester', 'README', 'active', 'README.md'); pipelineStep('tester', 'README', 'active', 'README.md');
const readmePrompt = `Write a minimal README.md. Include ONLY: const readmePrompt = `Write a minimal README.md. Include ONLY:
1. One-line description 1. One-line description
2. Quick start: docker compose up 2. Quick start: docker compose up → open http://localhost:8000
3. Development: uv sync && uv run uvicorn main:app --reload 3. Development: uv sync && uv run uvicorn main:app --reload → http://localhost:8000
4. API endpoints (if applicable) 4. API endpoints (if applicable)
5. Testing: uv run pytest 5. Testing: uv run pytest
Max 20 lines. Max 20 lines.
@@ -2651,20 +2803,192 @@ ${allFiles}`;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepFix}] DevOps</span> — korjaukset`); termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepFix}] DevOps</span> — korjaukset`);
pipelineStep('tester', 'Korjaukset', 'active', validation); pipelineStep('tester', 'Korjaukset', 'active', validation);
// Korjataan vain Dockerfile ja docker-compose // Korjataan vain Dockerfile ja docker-compose
const fixPrompt = `Fix ONLY the Dockerfile based on this feedback. Output the corrected Dockerfile, nothing else. // Korjataan koodatiedostot (ei Dockerfilea — se on template)
const fixableFiles = Object.entries(generatedFiles)
.filter(([n]) => n.endsWith('.py') || n === 'pyproject.toml')
.map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n');
const fixPrompt = `Fix the code files based on this feedback. Output corrected code only.
Do NOT output Dockerfile or docker-compose.yml — those are auto-generated.
Feedback: ${validation} Feedback: ${validation}
Current files: ${Object.keys(generatedFiles).join(', ')} ${fixableFiles}`;
Current Dockerfile: const fixedCode = await kpnRun(agentPrompts.coder.model, fixPrompt, false, 512);
${generatedFiles['Dockerfile'] || '(puuttuu)'}`; // Ei ylikirjoiteta Dockerfilea — generoidaan template uudelleen
const fixedDockerfile = await kpnRun(agentPrompts.tester.model, fixPrompt, false, 256); if (fixedCode) {
if (fixedDockerfile) generatedFiles['Dockerfile'] = fixedDockerfile; termLog(` <span style="color:#8b949e">Korjaukset generoitu</span>`);
pipelineStep('tester', 'Korjaukset', 'done', 'Dockerfile korjattu', fixedDockerfile); }
pipelineStep('coder', 'Korjaukset', 'done', 'Korjaukset generoitu', fixedCode);
} }
// Generoidaan projektin dokumentaatio HTML-raporttina
const reportHtml = generateProjectReport(task, generatedFiles, pipelineSteps, staticIssues);
const reportBlob = new Blob([reportHtml], { type: 'text/html' });
const reportUrl = URL.createObjectURL(reportBlob);
// Pipeline valmis — sammutetaan kaikki avataret
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('active'));
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); termLog(` <a href="${reportUrl}" target="_blank" style="color:#58a6ff;text-decoration:underline;cursor:pointer">📄 Avaa projektiraportti</a>`);
renderProjectCard(generatedFiles, task, reportUrl);
}
function generateProjectReport(task, files, steps, staticIssues) {
const fileEntries = Object.entries(files);
const agentNames = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data' };
const agentColors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };
// Syntaksikorostus: kevyt regex-pohjainen highlighter
function highlightCode(code, filename) {
let h = code.replace(/&/g,'&amp;').replace(/</g,'&lt;');
if (filename.endsWith('.py') || filename.endsWith('.toml') || filename.endsWith('.yml') || filename.endsWith('.yaml')) {
// Kommentit
h = h.replace(/(#[^\n]*)/g, '<span style="color:#8b949e;font-style:italic">$1</span>');
// Stringit (kolmois- ja yksittäiset)
h = h.replace(/("""[\s\S]*?"""|'''[\s\S]*?''')/g, '<span style="color:#a5d6ff">$1</span>');
h = h.replace(/((?<![\\])(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))/g, '<span style="color:#a5d6ff">$1</span>');
// Avainsanat
h = h.replace(/\b(def|class|import|from|return|if|elif|else|for|in|while|try|except|finally|with|as|raise|yield|async|await|True|False|None|not|and|or|is|lambda)\b/g, '<span style="color:#ff7b72">$1</span>');
// Dekoraattorit
h = h.replace(/^(\s*@\w+)/gm, '<span style="color:#d2a8ff">$1</span>');
// Numerot
h = h.replace(/\b(\d+\.?\d*)\b/g, '<span style="color:#79c0ff">$1</span>');
} else if (filename.endsWith('.html')) {
h = h.replace(/(&lt;\/?[\w-]+)/g, '<span style="color:#7ee787">$1</span>');
h = h.replace(/([\w-]+)=/g, '<span style="color:#79c0ff">$1</span>=');
h = h.replace(/((?<!=)"[^"]*")/g, '<span style="color:#a5d6ff">$1</span>');
} else if (filename === 'Dockerfile') {
h = h.replace(/^(FROM|RUN|COPY|WORKDIR|EXPOSE|CMD|ENV|ARG|ENTRYPOINT|ADD|VOLUME|LABEL|USER)/gm, '<span style="color:#ff7b72">$1</span>');
h = h.replace(/(#[^\n]*)/g, '<span style="color:#8b949e;font-style:italic">$1</span>');
h = h.replace(/((?<!=)"[^"]*")/g, '<span style="color:#a5d6ff">$1</span>');
}
return h;
}
const stepsHtml = steps.map((s, i) => {
const color = agentColors[s.agent] || '#8b949e';
const icon = s.status === 'done' ? '✓' : '◷';
const outputPreview = (s.output || '').substring(0, 500);
const highlighted = outputPreview ? highlightCode(outputPreview, s.label) : '';
return `
<div style="margin-bottom:12px;border:1px solid #30363d;border-radius:8px;overflow:hidden">
<div style="background:#161b22;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d">
<span><span style="color:${s.status === 'done' ? '#3fb950' : '#d29922'};font-size:14px">${icon}</span> <strong style="color:${color}">${agentNames[s.agent] || s.agent}</strong> <span style="color:#8b949e">—</span> ${s.label}</span>
<span style="color:#484f58;font-size:11px;background:#0d1117;padding:2px 8px;border-radius:10px">Vaihe ${i + 1}</span>
</div>
${s.input ? `<details><summary style="padding:6px 14px;color:#8b949e;font-size:12px;cursor:pointer;border-bottom:1px solid #21262d">▸ Prompti</summary><pre style="margin:0;padding:10px 14px;background:#010409;font-size:11px;overflow-x:auto;white-space:pre-wrap;color:#8b949e;line-height:1.5">${s.input.replace(/</g,'&lt;').substring(0, 1000)}</pre></details>` : ''}
${highlighted ? `<pre style="margin:0;padding:10px 14px;background:#0d1117;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9;line-height:1.6">${highlighted}</pre>` : ''}
</div>`;
}).join('');
const filesHtml = fileEntries.map(([name, content]) => {
const ext = name.split('.').pop();
const langLabel = {py:'Python',html:'HTML',toml:'TOML',yml:'YAML',yaml:'YAML',md:'Markdown'}[ext] || ext.toUpperCase();
const lines = (content || '').split('\\n').length;
return `
<div style="margin-bottom:12px;border:1px solid #30363d;border-radius:8px;overflow:hidden">
<div style="background:#161b22;padding:8px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d">
<span style="font-weight:600;color:#58a6ff">${name}</span>
<span style="color:#484f58;font-size:11px">${langLabel} · ${lines} riviä</span>
</div>
<pre style="margin:0;padding:12px 14px;background:#010409;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9;line-height:1.6">${highlightCode(content || '', name)}</pre>
</div>`;
}).join('');
const staticHtml = (staticIssues || []).length > 0
? `<div style="margin-bottom:16px;padding:12px;background:#1c1206;border:1px solid #d29922;border-radius:6px">
<strong style="color:#d29922">Staattinen analyysi (${staticIssues.length} huomautusta)</strong>
<ul style="margin:8px 0 0;padding-left:20px;color:#d29922">${staticIssues.map(i => `<li>${i}</li>`).join('')}</ul>
</div>`
: '<p style="color:#3fb950">✓ Staattinen analyysi: ei huomautuksia</p>';
return `<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kipinä Raportti — ${task}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; max-width: 960px; margin: 0 auto; line-height: 1.5; }
h1 { color: #f0f6fc; margin-bottom: 4px; font-size: 24px; }
h2 { color: #c9d1d9; margin: 28px 0 14px; border-bottom: 1px solid #30363d; padding-bottom: 8px; font-size: 18px; }
h3 { color: #8b949e; margin: 16px 0 8px; }
pre { font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace; tab-size: 4; }
a { color: #58a6ff; }
details summary { list-style: none; cursor: pointer; }
details summary::-webkit-details-marker { display: none; }
details[open] summary { color: #58a6ff; }
.sl-badge { display: inline-block; border: 1px solid; border-radius: 6px; padding: 3px 10px; margin: 2px; font-size: 11px; cursor: help; white-space: nowrap; position: relative; transition: transform 0.15s; }
.sl-badge:hover { transform: translateY(-1px); filter: brightness(1.3); }
.sl-badge[data-tip]:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: #1c2028; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; font-size: 11px; color: #c9d1d9; white-space: pre-wrap; max-width: 350px; min-width: 180px; z-index: 10; box-shadow: 0 4px 12px rgba(0,0,0,0.5); pointer-events: none; line-height: 1.5; }
</style>
</head>
<body>
<h1>🔥 Kipinä Projektiraportti</h1>
<p style="color:#8b949e;margin-bottom:20px">${task}${new Date().toLocaleString('fi-FI')}${fileEntries.length} tiedostoa, ${steps.length} vaihetta</p>
<h2>🔄 Agenttien workflow</h2>
${generateWorkflowSwimlane(steps)}
<h2>📋 Pipeline-vaiheet</h2>
${stepsHtml}
<h2>🔍 Staattinen analyysi</h2>
${staticHtml}
<h2>📁 Tiedostot</h2>
${filesHtml}
<hr style="border-color:#30363d;margin:24px 0">
<p style="color:#8b949e;font-size:12px">Generoitu Kipinä Agentic Playground v0.2.4 — <a href="https://kipina.studio">kipina.studio</a></p>
</body></html>`;
}
function generateWorkflowSwimlane(steps) {
const agentLabels = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data' };
const agentColors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };
const agentBgs = { manager: '#1c1206', coder: '#0d1a0d', tester: '#0d1520', qa: '#170d22', data: '#1a0d22' };
const stepDescs = { 'Suunnittelu': 'Jakaa projektin tiedostoiksi', 'Review': 'Tarkistaa koodin laadun', 'Testit': 'Kirjoittaa pytest-testit', 'Dockerfile': 'Generoi Docker-imagen', 'Compose': 'Palvelumääritys', 'README': 'Käyttöohjeet', 'Validointi': 'Tarkistaa yhteensopivuuden', 'Korjaukset': 'Korjaa löydetyt ongelmat' };
var agents = [];
var agentMap = {};
for (var si = 0; si < steps.length; si++) {
var s = steps[si];
if (!agentMap[s.agent]) { agentMap[s.agent] = []; agents.push(s.agent); }
agentMap[s.agent].push(s);
}
var rows = '';
for (var ai = 0; ai < agents.length; ai++) {
var agent = agents[ai];
var color = agentColors[agent] || '#8b949e';
var bg = agentBgs[agent] || '#161b22';
var label = agentLabels[agent] || agent;
var badges = '';
var aSteps = agentMap[agent];
for (var bi = 0; bi < aSteps.length; bi++) {
var st = aSteps[bi];
var desc = stepDescs[st.label] || st.label;
var outPrev = (st.output || '').substring(0, 150).replace(/</g, '&lt;').replace(/"/g, '&quot;');
// Rivitys tooltipiin: jokainen rivi omalle rivilleen
var tipLines = [desc];
if (outPrev) {
tipLines.push('');
var outLines = outPrev.split(/\n/).slice(0, 6);
for (var li = 0; li < outLines.length; li++) tipLines.push(outLines[li]);
if ((st.output || '').split(/\n/).length > 6) tipLines.push('...');
}
var icon = st.status === 'done' ? '✓' : '◷';
if (bi > 0) badges += '<span style="color:#484f58;margin:0 2px">→</span>';
badges += '<span class="sl-badge" style="background:' + bg + ';border-color:' + color + ';color:' + color + '" data-tip="' + tipLines.join('&#10;') + '">' + icon + ' ' + st.label.replace(/</g, '&lt;') + '</span>';
}
rows += '<div style="display:flex;align-items:center;gap:10px;margin:5px 0;padding:4px 0;border-bottom:1px solid #21262d"><span style="min-width:70px;font-weight:600;font-size:12px;color:' + color + '">' + label + '</span><div style="display:flex;flex-wrap:wrap;align-items:center;gap:2px">' + badges + '</div></div>';
}
return '<div style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:14px 16px">' + rows + '</div>';
} }
// Yksinkertainen pipeline (vanha: manageri → koodari → testaaja) // Yksinkertainen pipeline (vanha: manageri → koodari → testaaja)
@@ -2983,7 +3307,7 @@ ${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
'kpn run coder-3b': ['"binary search tree in rust"', '"REST API with Flask"', '"async web scraper in python"'], 'kpn run coder-3b': ['"binary search tree in rust"', '"REST API with Flask"', '"async web scraper in python"'],
'kpn run manager': ['"suunnittele REST API"', '"priorisoi tiimin tehtävät"'], 'kpn run manager': ['"suunnittele REST API"', '"priorisoi tiimin tehtävät"'],
'kpn run tester': ['"testaa login-toiminto"'], 'kpn run tester': ['"testaa login-toiminto"'],
'kpn project': ['"FastAPI + SQLite REST API for users"', '"Flask todo app with database"', '"CLI tool for CSV processing in Python"'], 'kpn project': ['"FastAPI + SQLite CRUD API for users (create, list, update, delete) with HTML form UI served at /"', '"FastAPI todo app with SQLite: CRUD (add, list, toggle done, delete) with simple HTML UI at /"', '"FastAPI bookmark manager with SQLite: CRUD (add, list, edit, delete) with HTML search UI at /"'],
'kpn pipeline': ['"rakenna todo-sovellus"', '"tee laskin pythonilla"'], 'kpn pipeline': ['"rakenna todo-sovellus"', '"tee laskin pythonilla"'],
}; };
@@ -3394,11 +3718,7 @@ ${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild); while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
term.scrollTop = term.scrollHeight; term.scrollTop = term.scrollHeight;
// Avatar-aktivointi vain oikeille käyttäjäpyynnöille // Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
if (data.task_id) {
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
document.getElementById('avatar-kpn')?.classList.add('active');
}
} }
} else if (isCoder) { } else if (isCoder) {
// Codelab: erillinen addCodeResult-handler käsittelee (rivi 2364) // Codelab: erillinen addCodeResult-handler käsittelee (rivi 2364)
@@ -3545,26 +3865,7 @@ ${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
term.scrollTop = term.scrollHeight; term.scrollTop = term.scrollHeight;
} }
// Avatar-aktivointi vain oikeille käyttäjäpyynnöille (task_id) // Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
if (data.task_id) {
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
const model = data.model || '';
const p = data.prompt ? data.prompt.toLowerCase() : '';
if (p.includes('tiiminvetäjä') || p.includes('pilko')) {
document.getElementById('avatar-kpn')?.classList.add('active');
} else if (p.includes('arvioi seuraava koodi') || p.includes('ohjelmiston julkaisu')) {
document.getElementById('avatar-tester')?.classList.add('active');
} else if (p.includes('tervehdi')) {
document.getElementById('avatar-client')?.classList.add('active');
} else if (p.includes('test')) {
document.getElementById('avatar-qa')?.classList.add('active');
} else if (model.includes('coder') || model.includes('Coder')) {
document.getElementById('avatar-coder')?.classList.add('active');
} else if (model.includes('deepseek') || model.includes('r1')) {
document.getElementById('avatar-observer')?.classList.add('active');
}
}
} }
} }
} catch(e) {} } catch(e) {}
@@ -4263,6 +4564,7 @@ ${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
function inlineFormat(text) { function inlineFormat(text) {
return text return text
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;border:1px solid #30363d;margin:12px 0;display:block">')
.replace(/`([^`]+)`/g, '<code style="background:#161b22;padding:2px 6px;border-radius:3px;font-size:13px;color:#e6edf3">$1</code>') .replace(/`([^`]+)`/g, '<code style="background:#161b22;padding:2px 6px;border-radius:3px;font-size:13px;color:#e6edf3">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong style="color:#e6edf3">$1</strong>') .replace(/\*\*([^*]+)\*\*/g, '<strong style="color:#e6edf3">$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>'); .replace(/\*([^*]+)\*/g, '<em>$1</em>');