7 Commits

Author SHA1 Message Date
Jaakko Vanhala
68c7195d54 TEMPLATING.md: periaatteet rakennuspalapohjaiselle koodigeneroinnille 2026-04-13 06:59:12 +03:00
Jaakko Vanhala
3d20238eef Uudelleenreititystä ja templatingia 2026-04-13 06:54:56 +03:00
Jaakko Vanhala
8b8ba01af3 Toipuminen yhteyskatkoksesta: hub ilmoittaa API:lle, node reconnectaa
- Hub: kun node katoaa kesken tehtävän, palauttaa virheen API-kutsulle
- Hub: node_active_task seuraa mikä tehtävä on kesken
- Hub: timeout 600s → 120s
- Node: reconnect nollaa busy-tilan ja näyttää sen TUI:ssa
2026-04-13 06:50:45 +03:00
Jaakko Vanhala
a3b95a56e8 Native node: timestamp lokiin, node_id otsikkoon, yhdistetty-tila 2026-04-13 06:32:11 +03:00
Jaakko Vanhala
5b20ebe800 Opas: terminologia korjattu — relaatio on taulu, relationship on yhteys 2026-04-12 20:32:52 +03:00
Jaakko Vanhala
ffe9bd6902 Opas päivitetty: relaatiotuki, architect-agentin rooli, vertailuluvut 2026-04-12 20:27:41 +03:00
Jaakko Vanhala
d27068b11a UI-korjaus korjattu (GTFO gemini) 2026-04-12 20:18:39 +03:00
10 changed files with 422 additions and 47 deletions

157
TEMPLATING.md Normal file
View File

@@ -0,0 +1,157 @@
# Templating — rakennuspalaset koodigeneroinnissa
## Perusperiaate
Kielimalli päättää **mitä** rakennetaan (entiteetit, kentät, tyypit, yhteydet).
Template-funktiot päättävät **miten** se rakennetaan (importit, engine setup, testikonfiguraatio).
```
Projektikuvaus → LLM → JSON-speksi → Templateit → Koodi → Validointi
```
LLM:n kontribuutio on yksi JSON-rakenne. Kaikki muu on determinististä —
sama speksi tuottaa aina saman koodin.
## Miksi tämä toimii
Pienen kielimallin (0.5B7B) vahvuudet ja heikkoudet ovat epäsymmetrisiä:
| Tehtävä | LLM:n kyky | Ratkaisu |
|---------|-----------|----------|
| Tunnista entiteetit kuvauksesta | Hyvä | LLM tekee |
| Valitse kenttätyypit | Hyvä | LLM tekee |
| Muista importit oikein | Huono | Template tekee |
| SQLite connect_args | Huono | Template tekee |
| Testikonfiguraatio | Huono | Template tekee |
| Dockerfile-rakenne | Huono | Template tekee |
Annetaan mallin tehdä se missä se on hyvä. Hoidetaan loput mekaanisesti.
## JSON-speksi
Kielimallin ainoa tuotos on JSON joka kuvaa projektin rakenteen:
```json
{
"project_name": "library-app",
"entities": [
{
"name": "Author",
"table_name": "authors",
"fields": [
{"name": "name", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null}
]
},
{
"name": "Book",
"table_name": "books",
"fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null},
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false, "default": null}
]
}
],
"relationships": [
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
],
"extra_imports": []
}
```
Speksin laatu ratkaisee kaiken. Hyvä speksi → hyvä projekti. Huono speksi →
teknisesti toimiva mutta sisällöllisesti väärä projekti.
## Architect-promptin rooli
Architect-agentti (JSON-speksin generoija) on kriittisin kohta koko pipelinessa.
Sitä ohjataan neljällä keinolla:
1. **Chain-of-thought** — malli miettii ensin entiteetit, sitten kentät,
sitten yhteydet, vasta lopuksi JSON
2. **Domain-esimerkit** — Todo, verkkokauppa, blogi — malli näkee miltä
hyvä speksi näyttää eri domaineissa
3. **Anti-patternit** — turhat ID-kentät, Enum-tyypit, suomenkieliset nimet
4. **Yhteyssäännöt** — jokainen `_id`-kenttä tarvitsee relationship-merkinnän
Isompi malli tässä yhdessä kohdassa parantaisi kaikkien projektien laatua.
## Templateit
Jokainen template on funktio joka ottaa speksin ja palauttaa koodia:
```
tmplModels(spec) → models.py (SQLAlchemy, ForeignKey, relationship)
tmplSchemas(spec) → schemas.py (Pydantic Create/Response/Detail)
tmplMain(spec) → main.py (FastAPI CRUD + nested endpoints + FK-validointi)
tmplTests(spec) → test_main.py (pytest + TestClient + helper-funktiot)
tmplPyproject(spec) → pyproject.toml (PEP 621)
tmplDockerfile() → Dockerfile (uv + non-root user)
```
Templateit generoivat automaattisesti:
- ForeignKey-constraintit ja relationship()-määrittelyt
- Nested endpointit (`GET /authors/{id}/books/`)
- FK-validointi (404 jos parent-entiteettiä ei ole)
- Detail-schemat (Book + author-data mukana)
- Test-helperit jotka luovat parent-entiteetit ensin
- Bad FK -testit (varmistaa että orpo-validointi toimii)
## Validointi
Generoitu koodi validoidaan mekaanisesti ennen käyttöä:
- Syntaksitarkistus (AST parse)
- Projektin sisäiset importit (löytyykö nimi lähdetiedostosta)
- SQLite connect_args
- Relatiiviset importit (kielletty)
- Testien rakenne (ei saa kopioida appia)
- pyproject.toml (ei poetryä)
- Dockerfile (ei poetryä, uv cache -oikeudet)
Docker-testi ajaa koko projektin: build → pytest → API smoke test.
## Rajoitukset
Templateit kattavat rakenteellisesti tunnetut projektit:
| Stack | Kattavuus |
|-------|-----------|
| FastAPI + SQLAlchemy CRUD | Toimii hyvin |
| Streamlit + DuckDB dashboard | Toimii hyvin |
| Muu | Ei templatea → ei toimi |
**Ei kata:**
- Custom business-logiikka (algoritmit, laskenta, ML)
- Epätyypilliset arkkitehtuurit (WebSocket, graafit, tapahtumapohjaiset)
- Frontend-sovellukset (React, Vue)
- Mikä tahansa mitä template ei tunne
Arvio: templateit kattavat ~20% kaikista mahdollisista projekteista, mutta juuri
sen 20% mitä opiskelu- ja prototyyppiympäristöissä tarvitaan useimmin.
## Laajentaminen
Uuden stackin lisääminen vaatii:
1. Uudet template-funktiot (käsityö, ~200400 riviä per stack)
2. JSON-speksin laajennos (uudet kentät jos tarvitaan)
3. Validointisäännöt uudelle stackille
4. Docker-testikonfiguraatio
Jokainen template on staattinen — se ei opi eikä sopeudu. Kattavuus kasvaa
vain kirjoittamalla lisää templateja.
## Hybridi: seuraava askel
Paras lopputulos syntyisi yhdistelmällä:
```
Speksi → Template (runko) → LLM (business-logiikka) → Validointi
```
Template tuottaa toimivan CRUD-pohjan. LLM lisää domain-kohtaisen logiikan
pienissä palasissa (yksi funktio kerrallaan). Mekaaninen validointi
tarkistaa jokaisen lisäyksen.
Tämä palauttaa LLM:n epäluotettavuuden takaisin peliin, mutta rajattuna:
virheet ovat paikallisia (yksi funktio) eivätkä rakenteellisia (koko projekti).

View File

@@ -321,35 +321,79 @@ Malli tuottaa JSON-rakenteen kuten:
Tämä on yksinkertainen tehtävä jossa pienikin malli onnistuu luotettavasti:
entiteettien tunnistus projektin kuvauksesta ja kenttätyyppien valinta.
Speksi sisältää myös **taulujen väliset yhteydet** (relationships):
```json
{
"entities": [
{"name": "Author", "table_name": "authors", "fields": [...]},
{"name": "Book", "table_name": "books", "fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false},
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false}
]}
],
"relationships": [
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
]
}
```
Templateit generoivat yhteyksistä automaattisesti:
- `ForeignKey('authors.id')` models.py:hin
- `relationship("Book", back_populates="author")` molempiin suuntiin
- `BookDetail`-schema jossa author-data mukana
- `GET /authors/{id}/books/` nested endpoint
- FK-validointi: 404 jos parent-entiteettiä ei ole
### Architect-agentti: speksin laatu ratkaisee
Arkkitehti on **kriittisin agentti** koko pipelinessa. Jos speksi on hyvä
(oikeat taulut, kentät, yhteydet), kaikki muu seuraa automaattisesti.
Jos speksi on huono, templateitkaan eivät pelasta.
Arkkitehtia ohjataan:
1. **Chain-of-thought**: "Mieti ensin taulut, sitten kentät, sitten yhteydet"
2. **Domain-esimerkit**: Todo, verkkokauppa, blogi — malli näkee miltä hyvä speksi näyttää
3. **Anti-patternit**: "Ei turhia ID-kenttiä, ei Enumeita, ei suomenkielisiä nimiä koodissa"
4. **Yhteyssäännöt**: "Jokainen `_id`-kenttä tarvitsee vastaavan relationship-merkinnän"
Isompi malli (tai API) tässä yhdessä kohdassa parantaa kaikkien projektien laatua
koska speksi on ainoa paikka jossa LLM:n ymmärrys vaikuttaa.
### Template täyttää loput
Jokainen template on kuin madlib — aukot täytetään speksin datalla:
**models.py template (yksinkertaistettu):**
```python
from sqlalchemy import create_engine, Column, Integer, {sa_types}
from sqlalchemy import create_engine, Column, Integer, {sa_types}, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship
# ... aina samat importit, engine setup, SessionLocal ...
class {entity.name}(Base):
__tablename__ = "{entity.table_name}"
id = Column(Integer, primary_key=True, index=True)
{field.name} = Column({field.sa_type}, nullable={field.nullable})
# ... jokainen kenttä speksistä ...
# FK-kentät: ForeignKey + relationship automaattisesti
{fk_field} = Column(Integer, ForeignKey('{parent_table}.id'))
{parent_lower} = relationship("{Parent}", back_populates="{children}")
```
Tulos: importit ovat aina oikein, `connect_args` on aina mukana,
testit importoivat aina `main.py`:stä eivätkä kopioi sitä.
taulujen yhteydet generoituvat oikein, testit importoivat `main.py`:stä eivätkä kopioi sitä.
### Vertailu: mittaustulokset
| | Vapaa generointi | Rakennuspalaset |
|---|:---:|:---:|
| LLM-kutsuja | 714 | **1** |
| Aika | 80120s | **~20s** |
| LLM-kutsuja | 714 | **3** (speksi + requirements + README) |
| Aika | 80120s | **~25s** |
| Syntaksi OK | ~70% | **100%** |
| Docker build | vaihteleva | **100%** |
| Pytest läpi | 0% | **100%** |
| API toimii | ~30% | **100%** |
| Taulujen yhteydet (FK) | ei koskaan | **100%** |
| Nested endpointit | ei koskaan | **automaattisesti** |
### Milloin kumpikin toimii

View File

@@ -2,7 +2,10 @@
<div id="panel-editor" class="panel">
<div style="display:flex;flex:1;min-height:0;gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden">
<div id="editor-filetree" style="width:200px;min-width:150px;background:var(--bg);border-right:1px solid var(--border);overflow:auto;resize:horizontal;font-family:'Courier New',monospace;font-size:13px">
<div style="padding:10px 12px;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">Tiedostot</div>
<div style="padding:10px 12px;color:#8b949e;font-size:11px;display:flex;justify-content:space-between;align-items:center;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">
<span>Tiedostot</span>
<button class="btn btn-green" style="padding:2px 6px;font-size:10px" onclick="downloadProjectZip()">.ZIP</button>
</div>
<div id="editor-file-list" style="padding:4px 0">
<div style="padding:8px 16px;color:#8b949e;font-size:12px">Generoi projekti:<br><code style="color:var(--accent)">kpn project "..."</code></div>
</div>

View File

@@ -1430,7 +1430,9 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
// Oppimispolku
renderLearnView(promptLog);
renderProjectCard(files, task);
termLog(`\n<span style="color:#8b949e">Siirretään tiedostot Editoriin...</span>`);
window._currentProjectName = task;
setTimeout(() => window.openInEditor(files), 1000);
}
async function kpnPipelineSimple(task) {
@@ -1456,41 +1458,7 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Done ━━━</span>`);
}
// === Project card ===
window._projectFiles = {}; // id → files
function renderProjectCard(files, name) {
const entries = Object.entries(files);
if (!entries.length) return;
const id = 'proj-' + Date.now();
window._projectFiles[id] = files;
const tabs = entries.map(([n],i) =>
`<div class="project-tab${i===0?' active':''}" data-card="${id}" data-i="${i}" onclick="switchProjTab('${id}',${i})">${esc(n)}</div>`
).join('');
const panels = entries.map(([n,c],i) =>
`<div class="proj-panel" data-card="${id}" data-i="${i}" style="${i>0?'display:none':''}">` +
`<div style="text-align:right;padding:4px 8px;background:var(--bg);border-bottom:1px solid #21262d">` +
`<button class="btn btn-muted" onclick="copyProjectFile('${id}','${esc(n)}')">Kopioi</button></div>` +
`<pre class="code-block">${highlightCode(c)}</pre></div>`
).join('');
const html = `<div id="${id}" class="project-card">` +
`<div class="project-header">` +
`<span style="color:var(--purple);font-weight:600">${esc(name||'Projekti')} <span style="color:#8b949e;font-weight:normal">(${entries.length})</span></span>` +
`<span style="display:flex;gap:6px">` +
`<button class="btn btn-muted" onclick="copyAllProjectFiles('${id}')">Kopioi kaikki</button>` +
`<button class="btn btn-muted" onclick="downloadProjectZip('${id}','${esc(name||'projekti')}')">Lataa .zip</button>` +
`<button class="btn btn-green" onclick="openInEditor(window._projectFiles['${id}'])">Avaa editorissa</button>` +
`</span></div>` +
`<div class="project-tabs">${tabs}</div>${panels}</div>`;
const div = document.createElement('div');
div.innerHTML = html;
termPanel.appendChild(div.firstElementChild);
termPanel.scrollTop = termPanel.scrollHeight;
}
// === Poistettiin renderProjectCard ja siirryttiin suoraan Editorin käyttöön ===
window.copyProjectFile = function(id, name) {
const files = window._projectFiles[id];
if (files && files[name]) navigator.clipboard.writeText(files[name]);
@@ -1501,8 +1469,9 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
const text = Object.entries(files).map(([n,c]) => '# --- ' + n + ' ---\n' + c).join('\n\n');
navigator.clipboard.writeText(text);
};
window.downloadProjectZip = function(id, name) {
const files = window._projectFiles[id];
window.downloadProjectZip = function() {
const files = window._currentEditorFiles;
const name = window._currentProjectName || 'projekti';
if (!files) return;
const enc = new TextEncoder();
const entries = Object.entries(files);
@@ -1618,6 +1587,7 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
const langMap = {py:'python',rs:'rust',js:'javascript',ts:'typescript',toml:'toml',json:'json',html:'html',css:'css',md:'markdown',txt:'plaintext'};
window.openInEditor = async function(files) {
window._currentEditorFiles = files;
switchTab('editor');
try { await initMonaco(); } catch(e) { console.error('Monaco-virhe:', e); return; }
const m = window.monaco;

View File

@@ -42,6 +42,7 @@ struct AppState {
node_types: Mutex<HashMap<u64, String>>, // node_id → "native" | "browser"
node_paused: Mutex<std::collections::HashSet<u64>>, // node_id → onko tauolla
node_busy: Mutex<std::collections::HashSet<u64>>, // Solmut joilla on aktiivinen tehtävä
node_active_task: Mutex<HashMap<u64, String>>, // node_id → task_id (mikä tehtävä on kesken)
pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi)
pending_responses: Mutex<HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>>, // task_id → oneshot API-vastaukselle
api_rate_limits: Mutex<HashMap<IpAddr, (std::time::Instant, u32)>>, // IP → (ikkuna-alku, pyyntömäärä)
@@ -329,6 +330,7 @@ async fn main() {
node_types: Mutex::new(HashMap::new()),
node_paused: Mutex::new(std::collections::HashSet::new()),
node_busy: Mutex::new(std::collections::HashSet::new()),
node_active_task: Mutex::new(HashMap::new()),
pending_task_ids: Mutex::new(std::collections::HashSet::new()),
pending_responses: Mutex::new(HashMap::new()),
api_rate_limits: Mutex::new(HashMap::new()),
@@ -908,6 +910,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
broadcast_stats(&state).await;
} else if msg_type == "pair_done" {
state.node_busy.lock().unwrap().remove(&node_id);
state.node_active_task.lock().unwrap().remove(&node_id);
{
let mut json = json; // Siirretään omistajuus muokkausta varten
if let Some(obj) = json.as_object_mut() {
@@ -994,6 +997,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
} else if msg_type == "llm_done" {
// Vapautetaan solmu ja tarkistetaan task_id:n aitous
state.node_busy.lock().unwrap().remove(&node_id);
state.node_active_task.lock().unwrap().remove(&node_id);
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
let valid_task = if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str())
@@ -1063,6 +1067,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
}
} else if msg_type == "llm_error" {
state.node_busy.lock().unwrap().remove(&node_id);
state.node_active_task.lock().unwrap().remove(&node_id);
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str());
@@ -1109,6 +1114,22 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
// Yhteys katkesi — merkitään session päättyneeksi ja siivotaan atomisesti
state.db.close_session(node_id);
// Jos solmulla oli kesken tehtävä, ilmoitetaan odottavalle API-kutsulle
let lost_task_id = state.node_active_task.lock().unwrap().remove(&node_id);
if let Some(tid) = lost_task_id {
tracing::warn!("Solmu {} katosi kesken tehtävän {} — palautetaan virhe API:lle", node_id, tid);
state.pending_task_ids.lock().unwrap().remove(&tid);
if let Some(resp_tx) = state.pending_responses.lock().unwrap().remove(&tid) {
let err = serde_json::json!({
"type": "llm_error",
"error": format!("Solmu #{} katosi kesken laskennan (task {})", node_id, tid),
"task_id": tid
});
let _ = resp_tx.send(err);
}
}
{
// Lukitaan kaikki kerralla, jotta solmu ei ole osittain siivottu
let mut tasks = state.node_tasks.lock().unwrap();
@@ -1308,6 +1329,7 @@ async fn api_chat_completions(
// Merkitään solmu varatuksi ja task_id jaetuksi
state.node_busy.lock().unwrap().insert(target_node_id);
state.node_active_task.lock().unwrap().insert(target_node_id, payload.task_id.clone());
state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone());
let mut msg = serde_json::json!({
@@ -1340,7 +1362,7 @@ async fn api_chat_completions(
}
}
let timeout = tokio::time::timeout(std::time::Duration::from_secs(600), resp_rx).await;
let timeout = tokio::time::timeout(std::time::Duration::from_secs(120), resp_rx).await;
match timeout {
Ok(Ok(v)) => {
@@ -1356,12 +1378,17 @@ async fn api_chat_completions(
}
}
Ok(Err(_)) => {
// Oneshot-kanava sulkeutui (solmu katosi)
// Oneshot-kanava sulkeutui (solmu katosi kesken laskennan)
state.pending_responses.lock().unwrap().remove(&payload.task_id);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response()
state.node_busy.lock().unwrap().remove(&target_node_id);
state.node_active_task.lock().unwrap().remove(&target_node_id);
(axum::http::StatusCode::SERVICE_UNAVAILABLE, "Solmu katosi kesken laskennan — yritä uudelleen").into_response()
}
Err(_) => {
// Timeout — solmu ei vastannut ajoissa
state.pending_responses.lock().unwrap().remove(&payload.task_id);
state.node_busy.lock().unwrap().remove(&target_node_id);
state.node_active_task.lock().unwrap().remove(&target_node_id);
(axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response()
}
}

131
network-poc/kipina-node Executable file
View File

@@ -0,0 +1,131 @@
#!/bin/bash
# Kipinä Node — lataa oikea binääri ja käynnistä
set -e
BASE_URL="https://kipina.studio/download"
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
# Tunnista OS ja arkkitehtuuri
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS-$ARCH" in
darwin-arm64) BINARY="kipina-node-macos-arm64" ;;
darwin-x86_64) BINARY="kipina-node-macos-arm64" ;; # Rosetta
linux-x86_64) BINARY="kipina-node-linux-x86_64" ;;
linux-aarch64) BINARY="kipina-node-linux-arm64" ;;
*) echo "Ei tuettu: $OS-$ARCH"; exit 1 ;;
esac
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ Kipinä Agentic Node ║"
echo " ╚══════════════════════════════════════╝"
echo ""
echo " OS: $OS ($ARCH)"
echo ""
# Etsi Ollama-instanssit
CANDIDATES=(
"http://localhost:11434"
"http://127.0.0.1:11434"
"http://ollama:11434"
"http://host.docker.internal:11434"
)
# Lisää OLLAMA_URL listaan jos asetettu ja ei jo mukana
if [ -n "$OLLAMA_URL" ]; then
ALREADY=false
for c in "${CANDIDATES[@]}"; do
[ "$c" = "$OLLAMA_URL" ] && ALREADY=true
done
$ALREADY || CANDIDATES=("$OLLAMA_URL" "${CANDIDATES[@]}")
fi
echo " Etsitään Ollama-instansseja..."
FOUND=()
for url in "${CANDIDATES[@]}"; do
if curl -s --connect-timeout 1 "$url/api/tags" &>/dev/null; then
FOUND+=("$url")
fi
done
if [ ${#FOUND[@]} -eq 0 ]; then
# Ei löytynyt — yritä käynnistää lokaali
if command -v ollama &>/dev/null; then
echo " Käynnistetään Ollama..."
ollama serve &>/dev/null &
sleep 3
if curl -s --connect-timeout 1 "http://localhost:11434/api/tags" &>/dev/null; then
OLLAMA_URL="http://localhost:11434"
echo " ✓ Ollama käynnistetty ($OLLAMA_URL)"
else
echo " ✗ Ollaman käynnistys epäonnistui."
exit 1
fi
else
echo ""
echo " ✗ Ollamaa ei löytynyt."
echo " Kontti/remote: OLLAMA_URL=http://HOST:11434 ./kipina-node"
echo " Asenna: curl -fsSL https://ollama.ai/install.sh | sh"
exit 1
fi
elif [ ${#FOUND[@]} -eq 1 ]; then
OLLAMA_URL="${FOUND[0]}"
echo " ✓ Ollama löytyi: $OLLAMA_URL"
else
echo ""
echo " Löytyi ${#FOUND[@]} Ollama-instanssia:"
echo ""
for i in "${!FOUND[@]}"; do
echo " $((i+1))) ${FOUND[$i]}"
done
echo ""
read -p " Valitse [1-${#FOUND[@]}]: " -r CHOICE
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le ${#FOUND[@]} ]; then
OLLAMA_URL="${FOUND[$((CHOICE-1))]}"
else
OLLAMA_URL="${FOUND[0]}"
echo " Käytetään oletusta: $OLLAMA_URL"
fi
echo " ✓ Valittu: $OLLAMA_URL"
fi
echo ""
echo " Hub: $HUB_URL"
echo " Ollama: $OLLAMA_URL"
if [ -n "$KIPINA_MODEL" ]; then
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
fi
# Lataa binääri
BIN_PATH="./kipina-node-bin"
if [ -f "$BIN_PATH" ]; then
echo ""
read -p " Löydettiin vanha kipina-node-bin lokaalisti. Haluatko poistaa sen ja ladata uusimman version? [Y/n] " -r DEL_CHOICE
if [[ "$DEL_CHOICE" =~ ^[Nn]$ ]]; then
echo " ✓ Käytetään lokaalia versiota."
else
rm -f "$BIN_PATH"
echo " ✓ Vanha binääri poistettu ja korvataan uudella."
fi
fi
if [ ! -f "$BIN_PATH" ]; then
echo " Ladataan tuorein $BINARY..."
curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
chmod +x "$BIN_PATH"
fi
echo ""
echo " ✓ Siirrytään Kipinä Noden hallintaan..."
echo " Ctrl+C pysäyttää"
echo ""
if [ -n "$KIPINA_MODEL" ]; then
export OLLAMA_MODEL="$KIPINA_MODEL"
fi
export HUB_URL="$HUB_URL"
export OLLAMA_URL="$OLLAMA_URL"
exec "$BIN_PATH"

BIN
network-poc/kipina-node-bin Executable file

Binary file not shown.

View File

@@ -23,3 +23,4 @@ dialoguer = "0.12.0"
ratatui = "0.29.0"
crossterm = { version = "0.28.1", features = ["event-stream"] }
tracing-appender = "0.2.4"
chrono = "0.4"

View File

@@ -401,6 +401,13 @@ async fn main() {
continue;
}
// Merkitään yhdistetyksi TUI:ssa
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("Network", "Yhdistetty hubiin".to_string(), None);
}
loop {
tokio::select! {
cmd = cmd_rx.recv() => {
@@ -497,6 +504,18 @@ async fn main() {
}
}
}
// Node joined → oma node_id
if text.contains(r#""type":"node_joined""#) {
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(nid) = msg.get("node_id").and_then(|v| v.as_u64()) {
let mut st = tui_state.write().await;
if st.node_id.is_none() {
st.node_id = Some(nid);
st.push_log("Network", format!("Node ID: #{}", nid), None);
}
}
}
}
// Verkon globaali tila
if text.contains(r#""type":"network_status""#) {
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&text) {
@@ -615,9 +634,27 @@ async fn main() {
}
}
// Yhteys katkesi — nollataan TUI:n busy-tila
{
let mut st = tui_state.write().await;
let lost_task = st.cur_task_id.clone();
if let Some(tid) = lost_task {
st.push_log("Network", format!("Tehtävä {} keskeytyi yhteyden katketessa", tid), None);
}
st.cur_task_id = None;
st.cur_prompt = None;
st.node_id = None;
st.status = "RECONNECTING".to_string();
st.push_log("Network", "Yhteys hubiin katkesi — yhdistetään uudelleen 5s...".to_string(), None);
}
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
}
Err(e) => {
{
let mut st = tui_state.write().await;
st.status = "RECONNECTING".to_string();
st.push_log("Network", format!("Yhdistäminen epäonnistui: {} — yritetään 5s...", e), None);
}
tracing::warn!("Hubiin yhdistäminen epäonnistui: {} — yritetään uudelleen 5s...", e);
}
}

View File

@@ -21,6 +21,7 @@ pub struct LogEntry {
pub ty: String,
pub msg: String,
pub speed: Option<f64>,
pub timestamp: String,
}
pub struct DashboardState {
@@ -62,7 +63,9 @@ impl DashboardState {
}
pub fn push_log(&mut self, ty: &str, msg: String, speed: Option<f64>) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
self.logs.push(LogEntry {
timestamp: now,
ty: ty.to_string(),
msg,
speed,
@@ -241,6 +244,8 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
};
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(&log.timestamp, Style::default().fg(Color::DarkGray)),
ratatui::text::Span::raw(" "),
ratatui::text::Span::styled(format!("{: <8}", log.ty), Style::default().fg(ty_color).add_modifier(Modifier::BOLD)),
ratatui::text::Span::raw(" | "),
ratatui::text::Span::styled(log.msg.clone(), Style::default().fg(Color::White)),