Compare commits
7 Commits
8468724a4c
...
68c7195d54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c7195d54 | ||
|
|
3d20238eef | ||
|
|
8b8ba01af3 | ||
|
|
a3b95a56e8 | ||
|
|
5b20ebe800 | ||
|
|
ffe9bd6902 | ||
|
|
d27068b11a |
157
TEMPLATING.md
Normal file
157
TEMPLATING.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Templating — rakennuspalaset koodigeneroinnissa
|
||||
|
||||
## Perusperiaate
|
||||
|
||||
Kielimalli päättää **mitä** rakennetaan (entiteetit, kentät, tyypit, yhteydet).
|
||||
Template-funktiot päättävät **miten** se rakennetaan (importit, engine setup, testikonfiguraatio).
|
||||
|
||||
```
|
||||
Projektikuvaus → LLM → JSON-speksi → Templateit → Koodi → Validointi
|
||||
```
|
||||
|
||||
LLM:n kontribuutio on yksi JSON-rakenne. Kaikki muu on determinististä —
|
||||
sama speksi tuottaa aina saman koodin.
|
||||
|
||||
## Miksi tämä toimii
|
||||
|
||||
Pienen kielimallin (0.5B–7B) vahvuudet ja heikkoudet ovat epäsymmetrisiä:
|
||||
|
||||
| Tehtävä | LLM:n kyky | Ratkaisu |
|
||||
|---------|-----------|----------|
|
||||
| Tunnista entiteetit kuvauksesta | Hyvä | LLM tekee |
|
||||
| Valitse kenttätyypit | Hyvä | LLM tekee |
|
||||
| Muista importit oikein | Huono | Template tekee |
|
||||
| SQLite connect_args | Huono | Template tekee |
|
||||
| Testikonfiguraatio | Huono | Template tekee |
|
||||
| Dockerfile-rakenne | Huono | Template tekee |
|
||||
|
||||
Annetaan mallin tehdä se missä se on hyvä. Hoidetaan loput mekaanisesti.
|
||||
|
||||
## JSON-speksi
|
||||
|
||||
Kielimallin ainoa tuotos on JSON joka kuvaa projektin rakenteen:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_name": "library-app",
|
||||
"entities": [
|
||||
{
|
||||
"name": "Author",
|
||||
"table_name": "authors",
|
||||
"fields": [
|
||||
{"name": "name", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Book",
|
||||
"table_name": "books",
|
||||
"fields": [
|
||||
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null},
|
||||
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false, "default": null}
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
|
||||
],
|
||||
"extra_imports": []
|
||||
}
|
||||
```
|
||||
|
||||
Speksin laatu ratkaisee kaiken. Hyvä speksi → hyvä projekti. Huono speksi →
|
||||
teknisesti toimiva mutta sisällöllisesti väärä projekti.
|
||||
|
||||
## Architect-promptin rooli
|
||||
|
||||
Architect-agentti (JSON-speksin generoija) on kriittisin kohta koko pipelinessa.
|
||||
Sitä ohjataan neljällä keinolla:
|
||||
|
||||
1. **Chain-of-thought** — malli miettii ensin entiteetit, sitten kentät,
|
||||
sitten yhteydet, vasta lopuksi JSON
|
||||
2. **Domain-esimerkit** — Todo, verkkokauppa, blogi — malli näkee miltä
|
||||
hyvä speksi näyttää eri domaineissa
|
||||
3. **Anti-patternit** — turhat ID-kentät, Enum-tyypit, suomenkieliset nimet
|
||||
4. **Yhteyssäännöt** — jokainen `_id`-kenttä tarvitsee relationship-merkinnän
|
||||
|
||||
Isompi malli tässä yhdessä kohdassa parantaisi kaikkien projektien laatua.
|
||||
|
||||
## Templateit
|
||||
|
||||
Jokainen template on funktio joka ottaa speksin ja palauttaa koodia:
|
||||
|
||||
```
|
||||
tmplModels(spec) → models.py (SQLAlchemy, ForeignKey, relationship)
|
||||
tmplSchemas(spec) → schemas.py (Pydantic Create/Response/Detail)
|
||||
tmplMain(spec) → main.py (FastAPI CRUD + nested endpoints + FK-validointi)
|
||||
tmplTests(spec) → test_main.py (pytest + TestClient + helper-funktiot)
|
||||
tmplPyproject(spec) → pyproject.toml (PEP 621)
|
||||
tmplDockerfile() → Dockerfile (uv + non-root user)
|
||||
```
|
||||
|
||||
Templateit generoivat automaattisesti:
|
||||
- ForeignKey-constraintit ja relationship()-määrittelyt
|
||||
- Nested endpointit (`GET /authors/{id}/books/`)
|
||||
- FK-validointi (404 jos parent-entiteettiä ei ole)
|
||||
- Detail-schemat (Book + author-data mukana)
|
||||
- Test-helperit jotka luovat parent-entiteetit ensin
|
||||
- Bad FK -testit (varmistaa että orpo-validointi toimii)
|
||||
|
||||
## Validointi
|
||||
|
||||
Generoitu koodi validoidaan mekaanisesti ennen käyttöä:
|
||||
|
||||
- Syntaksitarkistus (AST parse)
|
||||
- Projektin sisäiset importit (löytyykö nimi lähdetiedostosta)
|
||||
- SQLite connect_args
|
||||
- Relatiiviset importit (kielletty)
|
||||
- Testien rakenne (ei saa kopioida appia)
|
||||
- pyproject.toml (ei poetryä)
|
||||
- Dockerfile (ei poetryä, uv cache -oikeudet)
|
||||
|
||||
Docker-testi ajaa koko projektin: build → pytest → API smoke test.
|
||||
|
||||
## Rajoitukset
|
||||
|
||||
Templateit kattavat rakenteellisesti tunnetut projektit:
|
||||
|
||||
| Stack | Kattavuus |
|
||||
|-------|-----------|
|
||||
| FastAPI + SQLAlchemy CRUD | Toimii hyvin |
|
||||
| Streamlit + DuckDB dashboard | Toimii hyvin |
|
||||
| Muu | Ei templatea → ei toimi |
|
||||
|
||||
**Ei kata:**
|
||||
- Custom business-logiikka (algoritmit, laskenta, ML)
|
||||
- Epätyypilliset arkkitehtuurit (WebSocket, graafit, tapahtumapohjaiset)
|
||||
- Frontend-sovellukset (React, Vue)
|
||||
- Mikä tahansa mitä template ei tunne
|
||||
|
||||
Arvio: templateit kattavat ~20% kaikista mahdollisista projekteista, mutta juuri
|
||||
sen 20% mitä opiskelu- ja prototyyppiympäristöissä tarvitaan useimmin.
|
||||
|
||||
## Laajentaminen
|
||||
|
||||
Uuden stackin lisääminen vaatii:
|
||||
|
||||
1. Uudet template-funktiot (käsityö, ~200–400 riviä per stack)
|
||||
2. JSON-speksin laajennos (uudet kentät jos tarvitaan)
|
||||
3. Validointisäännöt uudelle stackille
|
||||
4. Docker-testikonfiguraatio
|
||||
|
||||
Jokainen template on staattinen — se ei opi eikä sopeudu. Kattavuus kasvaa
|
||||
vain kirjoittamalla lisää templateja.
|
||||
|
||||
## Hybridi: seuraava askel
|
||||
|
||||
Paras lopputulos syntyisi yhdistelmällä:
|
||||
|
||||
```
|
||||
Speksi → Template (runko) → LLM (business-logiikka) → Validointi
|
||||
```
|
||||
|
||||
Template tuottaa toimivan CRUD-pohjan. LLM lisää domain-kohtaisen logiikan
|
||||
pienissä palasissa (yksi funktio kerrallaan). Mekaaninen validointi
|
||||
tarkistaa jokaisen lisäyksen.
|
||||
|
||||
Tämä palauttaa LLM:n epäluotettavuuden takaisin peliin, mutta rajattuna:
|
||||
virheet ovat paikallisia (yksi funktio) eivätkä rakenteellisia (koko projekti).
|
||||
@@ -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 | 7–14 | **1** |
|
||||
| Aika | 80–120s | **~20s** |
|
||||
| LLM-kutsuja | 7–14 | **3** (speksi + requirements + README) |
|
||||
| Aika | 80–120s | **~25s** |
|
||||
| Syntaksi OK | ~70% | **100%** |
|
||||
| Docker build | vaihteleva | **100%** |
|
||||
| Pytest läpi | 0% | **100%** |
|
||||
| API toimii | ~30% | **100%** |
|
||||
| Taulujen yhteydet (FK) | ei koskaan | **100%** |
|
||||
| Nested endpointit | ei koskaan | **automaattisesti** |
|
||||
|
||||
### Milloin kumpikin toimii
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
131
network-poc/kipina-node
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# Kipinä Node — lataa oikea binääri ja käynnistä
|
||||
set -e
|
||||
|
||||
BASE_URL="https://kipina.studio/download"
|
||||
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
|
||||
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
|
||||
|
||||
# Tunnista OS ja arkkitehtuuri
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS-$ARCH" in
|
||||
darwin-arm64) BINARY="kipina-node-macos-arm64" ;;
|
||||
darwin-x86_64) BINARY="kipina-node-macos-arm64" ;; # Rosetta
|
||||
linux-x86_64) BINARY="kipina-node-linux-x86_64" ;;
|
||||
linux-aarch64) BINARY="kipina-node-linux-arm64" ;;
|
||||
*) echo "Ei tuettu: $OS-$ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo " ╔══════════════════════════════════════╗"
|
||||
echo " ║ Kipinä Agentic Node ║"
|
||||
echo " ╚══════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " OS: $OS ($ARCH)"
|
||||
echo ""
|
||||
|
||||
# Etsi Ollama-instanssit
|
||||
CANDIDATES=(
|
||||
"http://localhost:11434"
|
||||
"http://127.0.0.1:11434"
|
||||
"http://ollama:11434"
|
||||
"http://host.docker.internal:11434"
|
||||
)
|
||||
|
||||
# Lisää OLLAMA_URL listaan jos asetettu ja ei jo mukana
|
||||
if [ -n "$OLLAMA_URL" ]; then
|
||||
ALREADY=false
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
[ "$c" = "$OLLAMA_URL" ] && ALREADY=true
|
||||
done
|
||||
$ALREADY || CANDIDATES=("$OLLAMA_URL" "${CANDIDATES[@]}")
|
||||
fi
|
||||
|
||||
echo " Etsitään Ollama-instansseja..."
|
||||
FOUND=()
|
||||
for url in "${CANDIDATES[@]}"; do
|
||||
if curl -s --connect-timeout 1 "$url/api/tags" &>/dev/null; then
|
||||
FOUND+=("$url")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FOUND[@]} -eq 0 ]; then
|
||||
# Ei löytynyt — yritä käynnistää lokaali
|
||||
if command -v ollama &>/dev/null; then
|
||||
echo " Käynnistetään Ollama..."
|
||||
ollama serve &>/dev/null &
|
||||
sleep 3
|
||||
if curl -s --connect-timeout 1 "http://localhost:11434/api/tags" &>/dev/null; then
|
||||
OLLAMA_URL="http://localhost:11434"
|
||||
echo " ✓ Ollama käynnistetty ($OLLAMA_URL)"
|
||||
else
|
||||
echo " ✗ Ollaman käynnistys epäonnistui."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo " ✗ Ollamaa ei löytynyt."
|
||||
echo " Kontti/remote: OLLAMA_URL=http://HOST:11434 ./kipina-node"
|
||||
echo " Asenna: curl -fsSL https://ollama.ai/install.sh | sh"
|
||||
exit 1
|
||||
fi
|
||||
elif [ ${#FOUND[@]} -eq 1 ]; then
|
||||
OLLAMA_URL="${FOUND[0]}"
|
||||
echo " ✓ Ollama löytyi: $OLLAMA_URL"
|
||||
else
|
||||
echo ""
|
||||
echo " Löytyi ${#FOUND[@]} Ollama-instanssia:"
|
||||
echo ""
|
||||
for i in "${!FOUND[@]}"; do
|
||||
echo " $((i+1))) ${FOUND[$i]}"
|
||||
done
|
||||
echo ""
|
||||
read -p " Valitse [1-${#FOUND[@]}]: " -r CHOICE
|
||||
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le ${#FOUND[@]} ]; then
|
||||
OLLAMA_URL="${FOUND[$((CHOICE-1))]}"
|
||||
else
|
||||
OLLAMA_URL="${FOUND[0]}"
|
||||
echo " Käytetään oletusta: $OLLAMA_URL"
|
||||
fi
|
||||
echo " ✓ Valittu: $OLLAMA_URL"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Hub: $HUB_URL"
|
||||
echo " Ollama: $OLLAMA_URL"
|
||||
if [ -n "$KIPINA_MODEL" ]; then
|
||||
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
|
||||
fi
|
||||
|
||||
# Lataa binääri
|
||||
BIN_PATH="./kipina-node-bin"
|
||||
if [ -f "$BIN_PATH" ]; then
|
||||
echo ""
|
||||
read -p " Löydettiin vanha kipina-node-bin lokaalisti. Haluatko poistaa sen ja ladata uusimman version? [Y/n] " -r DEL_CHOICE
|
||||
if [[ "$DEL_CHOICE" =~ ^[Nn]$ ]]; then
|
||||
echo " ✓ Käytetään lokaalia versiota."
|
||||
else
|
||||
rm -f "$BIN_PATH"
|
||||
echo " ✓ Vanha binääri poistettu ja korvataan uudella."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$BIN_PATH" ]; then
|
||||
echo " Ladataan tuorein $BINARY..."
|
||||
curl -sSL "$BASE_URL/$BINARY?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
BIN
network-poc/kipina-node-bin
Executable file
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user