Compare commits
15 Commits
30e81875db
...
6b756e2e83
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b756e2e83 | |||
| 5a52f5113c | |||
| 7b0660e46e | |||
| b35600b417 | |||
| 7693269e5d | |||
| 702c9170ad | |||
| 3feed22055 | |||
| 75310c989e | |||
| 743946a391 | |||
| 0bd5faa684 | |||
| e0c8c3586b | |||
| 3a1c5c723c | |||
| 3139d1ac65 | |||
| 49a1629646 | |||
| 13008ac693 |
26
network-poc/TODO.md
Normal file
26
network-poc/TODO.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# TODO — Kipinä Agentic Network
|
||||
|
||||
## Turvallisuus
|
||||
- [ ] **Tulosten validointi** — solmu voi palauttaa haitallista koodia. Tarvitaan proof-of-work tai challenge-response -mekanismi
|
||||
- [ ] **Reputaatiojärjestelmä** — solmujen luotettavuuden seuranta: onnistuneet tehtävät, vasteaika, laatu
|
||||
- [ ] **Koodin sandboxaus** — generoitu koodi pitää ajaa eristetyssä ympäristössä ennen käyttäjälle näyttämistä
|
||||
- [ ] **Solmun identiteetti** — rekisteröityminen ja tunnistautuminen (API-avain / token)
|
||||
|
||||
## Yksityisyys
|
||||
- [ ] **Promptien salaus** — käyttäjän promptit menevät tuntemattomalle solmulle selkotekstinä
|
||||
- [ ] **End-to-end enkryptio** — hub ei näe promptin sisältöä, vain reitittää
|
||||
- [ ] **Tietosuojaseloste** — käyttäjille kerrottava miten data kulkee ja kuka sen näkee
|
||||
- [ ] **Opt-in malli** — käyttäjä valitsee haluaako käyttää yhteisösolmuja vai vain omaa
|
||||
|
||||
## Väärinkäytön esto
|
||||
- [ ] **Rate limiting per käyttäjä** — nykyinen IP-pohjainen ei riitä, tarvitaan autentikointi
|
||||
- [ ] **Solmun kuormitusraja** — solmu voi asettaa max tehtävät/minuutti
|
||||
- [ ] **Token-talous** — laskentaresurssien käyttö vaatii Kipinä-tokeneita (gamification jo aloitettu)
|
||||
- [ ] **Abuse reporting** — mekanismi haitallisten solmujen ilmiantamiseen
|
||||
|
||||
## Seuraavat ominaisuudet
|
||||
- [ ] Agenttien välinen keskustelu (manageri ohjaa dynaamisesti)
|
||||
- [ ] Tehtävähistoria ja tulosten tallennus
|
||||
- [ ] Prometheus/OpenTelemetry -metriikat
|
||||
- [ ] Solmujen terveystarkistukset (ping/pong)
|
||||
- [ ] Streaming-vastaukset Ollaman kautta
|
||||
Binary file not shown.
@@ -1015,7 +1015,7 @@ async fn api_chat_completions(
|
||||
*entry = (now, 1); // Uusi ikkuna
|
||||
} else {
|
||||
entry.1 += 1;
|
||||
if entry.1 > 10 {
|
||||
if entry.1 > 30 {
|
||||
return (axum::http::StatusCode::TOO_MANY_REQUESTS, "Liian monta pyyntöä — yritä minuutin kuluttua").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1076,6 +1076,9 @@
|
||||
<div id="shared-prompt-section" style="display:none;margin-top:8px;font-size:12px;color:#8b949e">
|
||||
Yhteinen konteksti liitetään jokaisen valitun agentin oman promptin alkuun.
|
||||
</div>
|
||||
<div id="agent-last-prompt" style="display:none;margin-top:8px">
|
||||
<button id="agent-open-modal-btn" style="background:#161b22;border:1px solid var(--accent-color);color:var(--accent-color);font-size:12px;padding:4px 12px;border-radius:4px;cursor:pointer;width:100%">📋 Näytä viimeisin prompti</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1246,6 +1249,17 @@
|
||||
textEl.value = sharedPrompt;
|
||||
sharedEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// Näytetään viimeisin pipeline-prompti valitulle agentille
|
||||
const lastPromptDiv = document.getElementById('agent-last-prompt');
|
||||
const lastPromptText = document.getElementById('agent-last-prompt-text');
|
||||
if (selectedAgents.size === 1) {
|
||||
const agent = [...selectedAgents][0];
|
||||
const lastStep = [...pipelineSteps].reverse().find(s => s.agent === agent && s.status === 'done' && s.input);
|
||||
lastPromptDiv.style.display = lastStep ? 'block' : 'none';
|
||||
} else {
|
||||
lastPromptDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
window.selectAgent = function(agent) {
|
||||
@@ -1302,6 +1316,96 @@
|
||||
saved._t = setTimeout(() => saved.style.opacity = '0', 1500);
|
||||
});
|
||||
|
||||
// Prompt-editori modal
|
||||
let modalAgent = null;
|
||||
let modalPromptParts = [];
|
||||
|
||||
function parsePromptToFields(prompt) {
|
||||
// Pilkotaan prompti avain-arvo-pareiksi
|
||||
const fields = [];
|
||||
const lines = prompt.split('\n');
|
||||
let currentKey = null;
|
||||
let currentVal = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Tunnistetaan avain: KEYWORD: tai KEYWORD — tai rivin alku isolla
|
||||
const keyMatch = line.match(/^(Project|CONSTRAINTS|EXAMPLE|RULES|IMPORTANT|Check|Files in project|Main app|Already written files|PROJECT CODE|Current code|Review feedback|Feedback)[\s:—]*(.*)/i);
|
||||
if (keyMatch) {
|
||||
if (currentKey) fields.push({ key: currentKey, value: currentVal.join('\n').trim(), editable: isEditable(currentKey) });
|
||||
currentKey = keyMatch[1];
|
||||
currentVal = keyMatch[2] ? [keyMatch[2]] : [];
|
||||
} else {
|
||||
currentVal.push(line);
|
||||
}
|
||||
}
|
||||
if (currentKey) fields.push({ key: currentKey, value: currentVal.join('\n').trim(), editable: isEditable(currentKey) });
|
||||
|
||||
// Jos ei löytynyt rakenteellisia avaimia, näytetään koko prompti yhtenä
|
||||
if (fields.length === 0) fields.push({ key: 'Prompti', value: prompt, editable: true });
|
||||
return fields;
|
||||
}
|
||||
|
||||
function isEditable(key) {
|
||||
const editableKeys = ['Project', 'CONSTRAINTS', 'IMPORTANT', 'Feedback', 'Review feedback'];
|
||||
return editableKeys.some(k => key.toLowerCase().includes(k.toLowerCase()));
|
||||
}
|
||||
|
||||
function openPromptModal(agent, label, prompt) {
|
||||
modalAgent = agent;
|
||||
modalPromptParts = parsePromptToFields(prompt);
|
||||
const modal = document.getElementById('prompt-modal');
|
||||
const title = document.getElementById('prompt-modal-title');
|
||||
const fields = document.getElementById('prompt-modal-fields');
|
||||
|
||||
const agentNames = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data' };
|
||||
title.textContent = `${agentNames[agent] || agent} — ${label}`;
|
||||
|
||||
fields.innerHTML = modalPromptParts.map((f, i) => `
|
||||
<div style="border:1px solid #21262d;border-radius:6px;overflow:hidden">
|
||||
<div style="background:#161b22;padding:6px 10px;font-size:12px;font-weight:600;color:${f.editable ? '#58a6ff' : '#8b949e'};display:flex;align-items:center;gap:6px">
|
||||
${f.editable ? '✏️' : '🔒'} ${f.key}
|
||||
</div>
|
||||
<textarea data-field-idx="${i}" ${f.editable ? '' : 'readonly'} style="width:100%;background:${f.editable ? '#010409' : '#0d1117'};border:none;color:${f.editable ? '#c9d1d9' : '#6e7681'};font-size:12px;font-family:'Courier New',monospace;padding:8px;resize:vertical;min-height:${f.value.split('\n').length > 3 ? '100' : '40'}px;outline:none;box-sizing:border-box">${f.value.replace(/</g,'<')}</textarea>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
modal.style.display = 'block';
|
||||
// Sulje klikatessa taustaa
|
||||
modal.onclick = (e) => { if (e.target === modal) closePromptModal(); };
|
||||
}
|
||||
window.openPromptModal = openPromptModal;
|
||||
|
||||
function closePromptModal() {
|
||||
document.getElementById('prompt-modal').style.display = 'none';
|
||||
}
|
||||
window.closePromptModal = closePromptModal;
|
||||
|
||||
function rerunFromModal() {
|
||||
// Kootaan prompti takaisin kentistä
|
||||
const fields = document.getElementById('prompt-modal-fields');
|
||||
const textareas = fields.querySelectorAll('textarea');
|
||||
const parts = [];
|
||||
textareas.forEach((ta, i) => {
|
||||
const key = modalPromptParts[i]?.key || '';
|
||||
const val = ta.value.trim();
|
||||
if (val) parts.push(`${key}: ${val}`);
|
||||
});
|
||||
const prompt = parts.join('\n\n');
|
||||
const model = agentPrompts[modalAgent]?.model || 'qwen-coder';
|
||||
closePromptModal();
|
||||
termLog(`<span class="terminal-prompt">$</span> <span style="color:#a371f7">↻ Aja uudelleen:</span> ${esc(modalAgent)}`);
|
||||
kpnRun(model, prompt);
|
||||
}
|
||||
window.rerunFromModal = rerunFromModal;
|
||||
|
||||
// "Näytä prompti" -nappi avaa modalin
|
||||
document.getElementById('agent-open-modal-btn')?.addEventListener('click', () => {
|
||||
if (selectedAgents.size !== 1) return;
|
||||
const agent = [...selectedAgents][0];
|
||||
const lastStep = [...pipelineSteps].reverse().find(s => s.agent === agent && s.status === 'done' && s.input);
|
||||
if (lastStep) openPromptModal(agent, lastStep.label, lastStep.input);
|
||||
});
|
||||
|
||||
function checkAgentConfusion() {
|
||||
Object.keys(agentPrompts).forEach(agent => {
|
||||
const prompt = agentPrompts[agent].prompt || "";
|
||||
@@ -2107,8 +2211,6 @@
|
||||
termLog(`<span style="color:#a371f7;font-weight:bold">━━━ Pipeline käynnistyy ━━━</span>`);
|
||||
|
||||
// Vaihe 1: Manageri pilkkoo projektin tiedostoiksi
|
||||
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`);
|
||||
pipelineStep('manager', 'Suunnittelu', 'active', task);
|
||||
const managerPrompt = `List the source files needed for this project. One file per line, format:
|
||||
filename.py: one-line description
|
||||
|
||||
@@ -2118,12 +2220,18 @@ CONSTRAINTS — the coder can only generate ~400 tokens per file:
|
||||
- Only .py and pyproject.toml files
|
||||
- No directories, no paths, just filenames
|
||||
- List dependencies first, then main app
|
||||
- Prefer fewer, focused files over many small ones
|
||||
|
||||
EXAMPLE for "FastAPI todo app with SQLite":
|
||||
pyproject.toml: project metadata and dependencies
|
||||
models.py: SQLAlchemy models and database setup
|
||||
main.py: FastAPI app with CRUD endpoints
|
||||
|
||||
Project: ${task}`;
|
||||
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`);
|
||||
pipelineStep('manager', 'Suunnittelu', 'active', managerPrompt);
|
||||
const plan = await kpnRun(agentPrompts.manager.model, managerPrompt, false, 200);
|
||||
if (!plan) { termLog(' ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; }
|
||||
pipelineStep('manager', 'Suunnittelu', 'done', task, plan);
|
||||
pipelineStep('manager', 'Suunnittelu', 'done', managerPrompt, plan);
|
||||
|
||||
// Parsitaan tiedostolista: "filename.py: description" TAI pelkkä "filename.py"
|
||||
const fileList = plan.split('\n')
|
||||
@@ -2160,7 +2268,7 @@ Project: ${task}`;
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i];
|
||||
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${i + 2}] Koodari</span> — ${esc(file.name)}`);
|
||||
pipelineStep('coder', file.name, 'active', file.desc);
|
||||
pipelineStep('coder', file.name, 'active', '');
|
||||
|
||||
// Rakennetaan konteksti: aiemmin generoidut tiedostot
|
||||
let context = '';
|
||||
@@ -2187,16 +2295,55 @@ start = "uvicorn main:app --reload"`;
|
||||
extraInstructions = '\nList one dependency per line. No version pins unless necessary.';
|
||||
}
|
||||
|
||||
const coderExample = file.name.includes('main') || file.name.includes('app')
|
||||
? `\nEXAMPLE output for a main.py:
|
||||
from fastapi import FastAPI, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from models import get_db, User
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/users")
|
||||
def list_users(db: Session = Depends(get_db)):
|
||||
return db.query(User).all()
|
||||
|
||||
@app.post("/users")
|
||||
def create_user(name: str, db: Session = Depends(get_db)):
|
||||
user = User(name=name)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return {"id": user.id, "name": user.name}`
|
||||
: file.name.includes('model')
|
||||
? `\nEXAMPLE output for a models.py:
|
||||
from sqlalchemy import create_engine, Column, Integer, String
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
engine = create_engine("sqlite:///app.db")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String)
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try: yield db
|
||||
finally: db.close()`
|
||||
: '';
|
||||
const coderPrompt = `${context}Project: ${task}
|
||||
Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions}
|
||||
IMPORTANT: Keep the code SHORT and focused. Max ~50 lines. No comments, no docstrings, no type hints unless essential. Write minimal, working code.`;
|
||||
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.`;
|
||||
const code = await kpnRun(agentPrompts.coder.model, coderPrompt);
|
||||
if (!code) {
|
||||
termLog(` ✗ Pipeline keskeytyi (${file.name})`, '#f85149');
|
||||
return;
|
||||
}
|
||||
generatedFiles[file.name] = code;
|
||||
pipelineStep('coder', file.name, 'done', file.desc, code);
|
||||
pipelineStep('coder', file.name, 'done', coderPrompt, code);
|
||||
}
|
||||
|
||||
// Vaihe 3: Testaaja arvioi koko projektin
|
||||
@@ -2238,8 +2385,24 @@ Write the corrected code.`;
|
||||
const step5 = fileList.length + (review && !review.toLowerCase().includes('lgtm') ? 5 : 3);
|
||||
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step5}] QA</span> — testit`);
|
||||
pipelineStep('qa', 'Testit', 'active', 'Kirjoitetaan testejä');
|
||||
const qaPrompt = `Write a short test file (test_app.py) for this project. Use pytest. Max 3 test functions. Keep it minimal.
|
||||
const qaPrompt = `Write test_app.py using pytest and FastAPI TestClient. Max 3 tests. Output ONLY code.
|
||||
|
||||
EXAMPLE:
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_create():
|
||||
r = client.post("/users", params={"name": "Test"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_list():
|
||||
r = client.get("/users")
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json(), list)
|
||||
|
||||
PROJECT CODE:
|
||||
${Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n')}`;
|
||||
const tests = await kpnRun(agentPrompts.qa.model, qaPrompt, false, 512);
|
||||
if (tests) generatedFiles['test_app.py'] = tests;
|
||||
@@ -2250,21 +2413,29 @@ ${Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\
|
||||
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step6}] DevOps</span> — Dockerfile`);
|
||||
pipelineStep('tester', 'Dockerfile', 'active', 'Dockerfile');
|
||||
const mainFile = Object.keys(generatedFiles).find(f => f.includes('main') || f.includes('app')) || Object.keys(generatedFiles)[0];
|
||||
const dockerPrompt = `Write a Dockerfile for this Python project using uv package manager.
|
||||
|
||||
RULES:
|
||||
- Base: python:3.12-slim
|
||||
- Install uv: COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
- COPY pyproject.toml and then: RUN uv sync --no-dev
|
||||
- COPY all .py files
|
||||
- EXPOSE 8000
|
||||
- CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
- Only output Dockerfile content, no explanations
|
||||
|
||||
Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
const dockerfile = await kpnRun(agentPrompts.tester.model, dockerPrompt, false, 256);
|
||||
if (dockerfile) generatedFiles['Dockerfile'] = dockerfile;
|
||||
pipelineStep('tester', 'Dockerfile', 'done', 'Dockerfile', dockerfile);
|
||||
const hasPyproject = 'pyproject.toml' in generatedFiles;
|
||||
const hasRequirements = 'requirements.txt' in generatedFiles;
|
||||
const pyFiles = Object.keys(generatedFiles).filter(f => f.endsWith('.py'));
|
||||
// Dockerfile-templatti: ei anneta mallin keksiä omaa
|
||||
let depLines;
|
||||
if (hasPyproject) {
|
||||
depLines = 'COPY pyproject.toml .\nRUN uv sync --no-dev';
|
||||
} else if (hasRequirements) {
|
||||
depLines = 'COPY requirements.txt .\nRUN uv pip install --system -r requirements.txt';
|
||||
} else {
|
||||
depLines = 'RUN uv pip install --system fastapi uvicorn';
|
||||
}
|
||||
const dockerfileContent = `FROM python:3.12-slim
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
WORKDIR /app
|
||||
${depLines}
|
||||
COPY ${pyFiles.join(' ')} ./
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]`;
|
||||
// Generoidaan Dockerfile suoraan templatesta, ei mallilla
|
||||
generatedFiles['Dockerfile'] = dockerfileContent;
|
||||
termLog(` <span style="color:#3fb950">✓</span> Dockerfile generoitu (template)`);
|
||||
pipelineStep('tester', 'Dockerfile', 'done', dockerfileContent, dockerfileContent);
|
||||
|
||||
// Vaihe 7: DevOps — docker-compose.yml
|
||||
const step7 = step6 + 1;
|
||||
@@ -2298,6 +2469,52 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
if (readme) generatedFiles['README.md'] = readme;
|
||||
pipelineStep('tester', 'README', 'done', 'README.md', readme);
|
||||
|
||||
// Validointivaihe: QA tarkistaa kaikkien tiedostojen yhteensopivuuden
|
||||
const stepV = step8 + 1;
|
||||
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${stepV}] QA</span> — validointi`);
|
||||
pipelineStep('qa', 'Validointi', 'active', 'Tarkistetaan yhteensopivuus');
|
||||
const allFiles = Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n');
|
||||
const validatePrompt = `You are a QA engineer. Check EVERY item below and report the result for each. Use this EXACT format:
|
||||
|
||||
1. Dockerfile COPY: ✓ OK / ✗ problem description
|
||||
2. Dockerfile deps vs imports: ✓ OK / ✗ problem description
|
||||
3. docker-compose ports: ✓ OK / ✗ problem description
|
||||
4. README commands: ✓ OK / ✗ problem description
|
||||
5. Test imports: ✓ OK / ✗ problem description
|
||||
6. pyproject.toml deps: ✓ OK / ✗ problem description
|
||||
|
||||
EXAMPLE output:
|
||||
1. Dockerfile COPY: ✓ OK — copies main.py and models.py which both exist
|
||||
2. Dockerfile deps: ✗ missing "sqlalchemy" in pip install
|
||||
3. docker-compose ports: ✓ OK — maps 8000:8000 matching EXPOSE
|
||||
4. README commands: ✓ OK — uvicorn main:app matches main.py
|
||||
5. Test imports: ✓ OK — imports main.app which exists
|
||||
6. pyproject.toml deps: ✓ OK — includes fastapi, uvicorn, sqlalchemy
|
||||
|
||||
Files in project: ${Object.keys(generatedFiles).join(', ')}
|
||||
|
||||
${allFiles}`;
|
||||
const validation = await kpnRun(agentPrompts.qa.model, validatePrompt, false, 256);
|
||||
pipelineStep('qa', 'Validointi', 'done', 'Yhteensopivuus', validation);
|
||||
|
||||
// Jos QA löysi ongelmia, korjataan
|
||||
if (validation && !validation.toLowerCase().startsWith('ok') && !validation.toLowerCase().includes('no issues') && !validation.toLowerCase().includes('everything is fine')) {
|
||||
const stepFix = stepV + 1;
|
||||
termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepFix}] DevOps</span> — korjaukset`);
|
||||
pipelineStep('tester', 'Korjaukset', 'active', validation);
|
||||
// Korjataan vain Dockerfile ja docker-compose
|
||||
const fixPrompt = `Fix ONLY the Dockerfile based on this feedback. Output the corrected Dockerfile, nothing else.
|
||||
|
||||
Feedback: ${validation}
|
||||
|
||||
Current files: ${Object.keys(generatedFiles).join(', ')}
|
||||
Current Dockerfile:
|
||||
${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
|
||||
const fixedDockerfile = await kpnRun(agentPrompts.tester.model, fixPrompt, false, 256);
|
||||
if (fixedDockerfile) generatedFiles['Dockerfile'] = fixedDockerfile;
|
||||
pipelineStep('tester', 'Korjaukset', 'done', 'Dockerfile korjattu', fixedDockerfile);
|
||||
}
|
||||
|
||||
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis (${Object.keys(generatedFiles).length} tiedostoa) ━━━</span>`);
|
||||
renderProjectCard(generatedFiles, task);
|
||||
}
|
||||
@@ -2383,6 +2600,11 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
termLog(` <span style="color:#d29922">→ korjattu: ${esc(cmd)}</span>`);
|
||||
}
|
||||
|
||||
// Oikotie: pelkkä numero → kpn load <numero>
|
||||
if (/^\d+$/.test(cmd.trim())) {
|
||||
cmd = 'kpn load ' + cmd.trim();
|
||||
termLog(` <span style="color:#d29922">→ ${esc(cmd)}</span>`);
|
||||
}
|
||||
const parts = cmd.trim().split(/\s+/);
|
||||
if (parts[0] !== 'kpn') {
|
||||
termLog('kpn: tuntematon komento. Kokeile: kpn help', '#f85149');
|
||||
@@ -2467,21 +2689,47 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
}
|
||||
// Ollama: vaihdetaan malli hubin kautta
|
||||
termLog(` Vaihdetaan Ollama-malli: ${selected.name} (${selected.size})...`, '#d29922');
|
||||
fetch('/api/v1/model', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: selected.name }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'ok') {
|
||||
termLog(` <span style="color:#3fb950">✓</span> Malli vaihdettu: ${selected.name}`, '#3fb950');
|
||||
termLog(' <span style="color:#8b949e">Ollama lataa mallin ensimmäisellä pyynnöllä</span>');
|
||||
// Päivitetään aktiivinen default
|
||||
// Tilaindikaattori
|
||||
const pullLine = document.createElement('div');
|
||||
pullLine.className = 'terminal-line term-pull';
|
||||
pullLine.innerHTML = ' <span style="color:#d29922">⠋ Ladataan...</span>';
|
||||
termPanel.appendChild(pullLine);
|
||||
termPanel.scrollTop = termPanel.scrollHeight;
|
||||
const spinFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
||||
let spinIdx = 0;
|
||||
const spinTimer = setInterval(() => {
|
||||
spinIdx = (spinIdx + 1) % spinFrames.length;
|
||||
const content = pullLine.querySelector('span');
|
||||
if (content) content.textContent = spinFrames[spinIdx] + ' Ladataan ' + selected.name + '...';
|
||||
}, 100);
|
||||
// Vaihdetaan malli hubille + Ollama pull
|
||||
Promise.all([
|
||||
fetch('/api/v1/model', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: selected.name }),
|
||||
}).then(r => r.json()),
|
||||
// Suora pull Ollamasta — odotetaan kunnes malli on ladattu
|
||||
fetch('http://' + window.location.hostname + ':11434/api/pull', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: selected.name, stream: false }),
|
||||
}).then(r => r.json()).catch(() => ({ status: 'ok' })),
|
||||
]).then(([hubData, _]) => {
|
||||
clearInterval(spinTimer);
|
||||
pullLine.remove();
|
||||
if (hubData.status === 'ok') {
|
||||
termLog(` <span style="color:#3fb950">✓</span> ${selected.name} ladattu ja aktiivinen`, '#3fb950');
|
||||
ollamaModels.forEach(m => m.default = false);
|
||||
selected.default = true;
|
||||
} else {
|
||||
termLog(` ✗ Mallin vaihto epäonnistui`, '#f85149');
|
||||
termLog(' ✗ Mallin vaihto epäonnistui', '#f85149');
|
||||
}
|
||||
}).catch(e => termLog(` ✗ ${e.message}`, '#f85149'));
|
||||
}).catch(e => {
|
||||
clearInterval(spinTimer);
|
||||
pullLine.remove();
|
||||
termLog(` ✗ ${e.message}`, '#f85149');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2493,14 +2741,31 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
}
|
||||
|
||||
if (sub === 'models') {
|
||||
termLog(' <span style="color:#d29922">Selain (kpn load):</span>', '#c9d1d9');
|
||||
termLog(' qwen-coder:0.5b <span style="color:#8b949e">~990 MB | WASM ~0.4 tok/s</span>');
|
||||
termLog(' <span style="color:#3fb950">Natiivi (Ollama + GPU):</span>', '#c9d1d9');
|
||||
termLog(' qwen2.5-coder:7b <span style="color:#8b949e">~4.7 GB | NVIDIA ~80 tok/s | AMD ~40 tok/s | Apple ~30 tok/s</span>');
|
||||
termLog(' qwen2.5-coder:3b <span style="color:#8b949e">~1.9 GB | NVIDIA ~120 tok/s</span>');
|
||||
termLog(' qwen2.5-coder:1.5b <span style="color:#8b949e">~1 GB | NVIDIA ~150 tok/s</span>');
|
||||
termLog(' Vaihda malli: <span style="color:#58a6ff">OLLAMA_MODEL=qwen2.5-coder:7b</span>', '#8b949e');
|
||||
termLog(' Hub reitittää automaattisesti nopeimmalle solmulle', '#8b949e');
|
||||
const allModels = [
|
||||
{ id: '1', name: 'qwen2.5-coder:0.5b', size: '~400 MB', type: 'selain + Ollama' },
|
||||
{ id: '2', name: 'qwen2.5-coder:1.5b', size: '~1 GB', type: 'Ollama GPU' },
|
||||
{ id: '3', name: 'qwen2.5-coder:7b', size: '~4.7 GB', type: 'Ollama GPU' },
|
||||
{ id: '4', name: 'qwen2.5-coder:14b', size: '~9 GB', type: 'Ollama GPU' },
|
||||
{ id: '5', name: 'qwen2.5-coder:32b', size: '~20 GB', type: 'Ollama GPU' },
|
||||
];
|
||||
// Haetaan ladatut mallit Ollamasta
|
||||
Promise.all([
|
||||
fetch('/api/v1/hardware').then(r => r.json()).catch(() => ({})),
|
||||
fetch('http://' + window.location.hostname + ':11434/api/tags').then(r => r.json()).catch(() => ({ models: [] })),
|
||||
]).then(([hw, ollama]) => {
|
||||
const loadedNames = (ollama.models || []).map(m => m.name.replace(':latest', ''));
|
||||
const btn = document.getElementById('agent-compute-btn');
|
||||
const wasmLoaded = btn?.dataset.state === 'ready';
|
||||
if (hw.gpu_name && hw.gpu_name !== 'ei natiivisolmua') {
|
||||
termLog(` <span style="color:#8b949e">GPU: ${hw.gpu_name} | VRAM: ${Math.round((hw.vram_mb||0)/1024)} GB</span>`);
|
||||
}
|
||||
termLog(' Mallit <span style="color:#8b949e">(kpn load <numero>)</span>:', '#c9d1d9');
|
||||
for (const m of allModels) {
|
||||
const loaded = (m.id === '1' && wasmLoaded) || loadedNames.some(n => m.name.includes(n) || n.includes(m.name.split(':')[1]));
|
||||
const status = loaded ? ' <span style="color:#3fb950">✓ ladattu</span>' : '';
|
||||
termLog(` <span style="color:#58a6ff">${m.id}</span> ${m.name} <span style="color:#8b949e">${m.size} | ${m.type}</span>${status}`);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2983,8 +3248,11 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
|
||||
term.scrollTop = term.scrollHeight;
|
||||
|
||||
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
|
||||
document.getElementById('avatar-kpn').classList.add('active');
|
||||
// Avatar-aktivointi vain oikeille käyttäjäpyynnöille
|
||||
if (data.task_id) {
|
||||
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
|
||||
document.getElementById('avatar-kpn')?.classList.add('active');
|
||||
}
|
||||
}
|
||||
} else if (isCoder) {
|
||||
// Codelab: erillinen addCodeResult-handler käsittelee (rivi 2364)
|
||||
@@ -3131,23 +3399,25 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
term.scrollTop = term.scrollHeight;
|
||||
}
|
||||
|
||||
// Avatar-aktivointi vain omille tehtäville
|
||||
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
|
||||
const model = data.model || '';
|
||||
const p = data.prompt ? data.prompt.toLowerCase() : '';
|
||||
// Avatar-aktivointi vain oikeille käyttäjäpyynnöille (task_id)
|
||||
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');
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3910,5 +4180,20 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
||||
return html;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Prompt-editori modal -->
|
||||
<div id="prompt-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:1000;backdrop-filter:blur(4px)">
|
||||
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#0d1117;border:1px solid #30363d;border-radius:8px;width:700px;max-width:90vw;max-height:85vh;overflow-y:auto;padding:20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<span id="prompt-modal-title" style="font-weight:600;font-size:15px;color:#58a6ff"></span>
|
||||
<button onclick="closePromptModal()" style="background:none;border:none;color:#8b949e;font-size:20px;cursor:pointer;padding:0 4px">×</button>
|
||||
</div>
|
||||
<div id="prompt-modal-fields" style="display:flex;flex-direction:column;gap:10px"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
||||
<button onclick="closePromptModal()" style="background:#161b22;border:1px solid #30363d;color:#8b949e;padding:6px 16px;border-radius:4px;cursor:pointer">Sulje</button>
|
||||
<button onclick="rerunFromModal()" style="background:#238636;border:1px solid #2ea043;color:white;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600">▶ Aja uudelleen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user