|
|
|
|
@@ -1131,7 +1131,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>
|
|
|
|
|
</span>
|
|
|
|
|
</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>
|
|
|
|
|
<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">
|
|
|
|
|
@@ -1459,12 +1459,14 @@ filename.py: one-line description
|
|
|
|
|
CONSTRAINTS: the coder can only generate ~400 tokens per file
|
|
|
|
|
- Max 3 files (keep it minimal)
|
|
|
|
|
- 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"
|
|
|
|
|
pyproject.toml: project metadata and dependencies
|
|
|
|
|
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)` },
|
|
|
|
|
coder: { label: 'Koodaus', prompt: `Project: (managerin suunnitelma)
|
|
|
|
|
@@ -1514,11 +1516,11 @@ IMPORTANT: Use uv for package management (uv sync, uv run)` },
|
|
|
|
|
data: { label: 'Data', prompt: `SQLAlchemy models and database setup.
|
|
|
|
|
|
|
|
|
|
EXAMPLE:
|
|
|
|
|
from sqlalchemy import create_engine, Column, Integer, String
|
|
|
|
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
|
|
|
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
|
|
|
|
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
|
|
|
|
|
|
|
|
|
engine = create_engine("sqlite:///app.db")
|
|
|
|
|
Base = declarative_base()
|
|
|
|
|
class Base(DeclarativeBase): pass
|
|
|
|
|
|
|
|
|
|
IMPORTANT: Include get_db() dependency for FastAPI` },
|
|
|
|
|
};
|
|
|
|
|
@@ -2145,7 +2147,7 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
|
|
|
|
|
pipelineSteps.push(step);
|
|
|
|
|
}
|
|
|
|
|
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 avatarId = avatarMap[agent];
|
|
|
|
|
if (avatarId) {
|
|
|
|
|
@@ -2153,6 +2155,19 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
|
|
|
|
|
if (el) {
|
|
|
|
|
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}...`;
|
|
|
|
|
|
|
|
|
|
// 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 +2176,16 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
|
|
|
|
|
const container = document.getElementById('pipeline-steps');
|
|
|
|
|
if (!container) return;
|
|
|
|
|
if (pipelineSteps.length === 0) { container.style.display = 'none'; return; }
|
|
|
|
|
container.style.display = 'block';
|
|
|
|
|
container.style.display = 'flex';
|
|
|
|
|
container.innerHTML = pipelineSteps.map((s, i) => {
|
|
|
|
|
const colors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };
|
|
|
|
|
const color = colors[s.agent] || '#8b949e';
|
|
|
|
|
const icon = s.status === 'done' ? '✓' : s.status === 'active' ? '◷' : '◯';
|
|
|
|
|
const iconColor = s.status === 'done' ? '#3fb950' : s.status === 'active' ? '#d29922' : '#8b949e';
|
|
|
|
|
const arrow = i < pipelineSteps.length - 1 ? ' <span style="color:#30363d">→</span> ' : '';
|
|
|
|
|
// Tooltip: input/output esikatselu
|
|
|
|
|
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 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' };
|
|
|
|
|
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('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -2191,7 +2207,7 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
|
|
|
|
|
// Globaali storage projektikorttien tiedostoille (välttää JSON data-attribuuttien ongelmat)
|
|
|
|
|
const projectFiles = {};
|
|
|
|
|
|
|
|
|
|
function renderProjectCard(files, projectName) {
|
|
|
|
|
function renderProjectCard(files, projectName, reportUrl) {
|
|
|
|
|
const fileEntries = Object.entries(files);
|
|
|
|
|
if (fileEntries.length === 0) return;
|
|
|
|
|
|
|
|
|
|
@@ -2219,6 +2235,7 @@ IMPORTANT: Include get_db() dependency for FastAPI` },
|
|
|
|
|
<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="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>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display:flex;gap:2px;padding:6px 8px 0;background:#0d1117">${tabsHtml}</div>
|
|
|
|
|
@@ -2363,14 +2380,16 @@ filename.py: one-line description
|
|
|
|
|
CONSTRAINTS — the coder can only generate ~400 tokens per file:
|
|
|
|
|
- Max 3 files (keep it minimal)
|
|
|
|
|
- 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
|
|
|
|
|
- List dependencies first, then main app
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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}`;
|
|
|
|
|
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`);
|
|
|
|
|
@@ -2433,7 +2452,7 @@ Project: ${task}`;
|
|
|
|
|
name = "projectname"
|
|
|
|
|
version = "0.1.0"
|
|
|
|
|
requires-python = ">=3.11"
|
|
|
|
|
dependencies = ["fastapi", "uvicorn", "sqlalchemy"]
|
|
|
|
|
dependencies = ["fastapi", "uvicorn", "sqlalchemy", "httpx", "pytest"]
|
|
|
|
|
|
|
|
|
|
[project.scripts]
|
|
|
|
|
start = "uvicorn main:app --reload"
|
|
|
|
|
@@ -2444,31 +2463,81 @@ IMPORTANT: Only list pip-installable packages. NEVER include Python stdlib modul
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const coderExample = file.name.includes('main') || file.name.includes('app')
|
|
|
|
|
? `\nEXAMPLE output for a main.py:
|
|
|
|
|
from fastapi import FastAPI, Depends
|
|
|
|
|
? `\nEXAMPLE output for a main.py (CRUD + HTML UI):
|
|
|
|
|
from fastapi import FastAPI, Depends, HTTPException
|
|
|
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
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.get("/users")
|
|
|
|
|
@app.get("/")
|
|
|
|
|
def index():
|
|
|
|
|
return FileResponse("index.html")
|
|
|
|
|
|
|
|
|
|
@app.get("/api/users")
|
|
|
|
|
def list_users(db: Session = Depends(get_db)):
|
|
|
|
|
return db.query(User).all()
|
|
|
|
|
|
|
|
|
|
@app.post("/users")
|
|
|
|
|
@app.post("/api/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}`
|
|
|
|
|
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')
|
|
|
|
|
? `\nEXAMPLE output for a models.py:
|
|
|
|
|
from sqlalchemy import create_engine, Column, Integer, String
|
|
|
|
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
|
|
|
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
|
|
|
|
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
|
|
|
|
|
|
|
|
|
engine = create_engine("sqlite:///app.db")
|
|
|
|
|
SessionLocal = sessionmaker(bind=engine)
|
|
|
|
|
Base = declarative_base()
|
|
|
|
|
class Base(DeclarativeBase): pass
|
|
|
|
|
|
|
|
|
|
class User(Base):
|
|
|
|
|
__tablename__ = "users"
|
|
|
|
|
@@ -2484,7 +2553,7 @@ def get_db():
|
|
|
|
|
: '';
|
|
|
|
|
const coderPrompt = `${context}Project: ${task}
|
|
|
|
|
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);
|
|
|
|
|
if (!code) {
|
|
|
|
|
termLog(` ✗ Pipeline keskeytyi (${file.name})`, '#f85149');
|
|
|
|
|
@@ -2499,17 +2568,103 @@ IMPORTANT: Keep the code SHORT. Max ~50 lines. No comments, no docstrings. Write
|
|
|
|
|
.map(([name, code]) => `--- ${name} ---\n${code}`)
|
|
|
|
|
.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`);
|
|
|
|
|
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}`;
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// 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`);
|
|
|
|
|
pipelineStep('coder', 'Korjaukset', 'active', review);
|
|
|
|
|
const fixPrompt = `Fix the issues found in the review.
|
|
|
|
|
@@ -2563,7 +2718,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 hasPyproject = 'pyproject.toml' 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
|
|
|
|
|
let depLines;
|
|
|
|
|
if (hasPyproject) {
|
|
|
|
|
@@ -2577,7 +2732,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
|
|
|
|
|
WORKDIR /app
|
|
|
|
|
${depLines}
|
|
|
|
|
COPY ${pyFiles.join(' ')} ./
|
|
|
|
|
COPY ${codeFiles.join(' ')} ./
|
|
|
|
|
EXPOSE 8000
|
|
|
|
|
CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]`;
|
|
|
|
|
// Generoidaan Dockerfile suoraan templatesta, ei mallilla
|
|
|
|
|
@@ -2589,16 +2744,21 @@ CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0
|
|
|
|
|
const step7 = step6 + 1;
|
|
|
|
|
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step7}] DevOps</span> — docker-compose.yml`);
|
|
|
|
|
pipelineStep('tester', 'Compose', 'active', 'docker-compose.yml');
|
|
|
|
|
const composePrompt = `Write a docker-compose.yml for this project. Include:
|
|
|
|
|
- app service (build from Dockerfile, port mapping, restart: unless-stopped)
|
|
|
|
|
- db service if SQLite/PostgreSQL is used (volume for data persistence)
|
|
|
|
|
- Named volumes for persistent data
|
|
|
|
|
Only output the YAML content, nothing else.
|
|
|
|
|
// docker-compose.yml templatesta (ei LLM:llä — vältetään version/postgres ongelmat)
|
|
|
|
|
const composeContent = `services:
|
|
|
|
|
app:
|
|
|
|
|
build: .
|
|
|
|
|
ports:
|
|
|
|
|
- "8000:8000"
|
|
|
|
|
volumes:
|
|
|
|
|
- app-data:/app/data
|
|
|
|
|
restart: unless-stopped
|
|
|
|
|
|
|
|
|
|
Files: ${Object.keys(generatedFiles).join(', ')}`;
|
|
|
|
|
const compose = await kpnRun(agentPrompts.tester.model, composePrompt, false, 256);
|
|
|
|
|
if (compose) generatedFiles['docker-compose.yml'] = compose;
|
|
|
|
|
pipelineStep('tester', 'Compose', 'done', 'docker-compose.yml', compose);
|
|
|
|
|
volumes:
|
|
|
|
|
app-data:`;
|
|
|
|
|
generatedFiles['docker-compose.yml'] = composeContent;
|
|
|
|
|
termLog(` <span style="color:#3fb950">✓</span> docker-compose.yml generoitu (template)`);
|
|
|
|
|
pipelineStep('tester', 'Compose', 'done', composeContent, composeContent);
|
|
|
|
|
|
|
|
|
|
// Vaihe 8: DevOps — README
|
|
|
|
|
const step8 = step7 + 1;
|
|
|
|
|
@@ -2606,8 +2766,8 @@ Files: ${Object.keys(generatedFiles).join(', ')}`;
|
|
|
|
|
pipelineStep('tester', 'README', 'active', 'README.md');
|
|
|
|
|
const readmePrompt = `Write a minimal README.md. Include ONLY:
|
|
|
|
|
1. One-line description
|
|
|
|
|
2. Quick start: docker compose up
|
|
|
|
|
3. Development: uv sync && uv run uvicorn main:app --reload
|
|
|
|
|
2. Quick start: docker compose up → open http://localhost:8000
|
|
|
|
|
3. Development: uv sync && uv run uvicorn main:app --reload → http://localhost:8000
|
|
|
|
|
4. API endpoints (if applicable)
|
|
|
|
|
5. Testing: uv run pytest
|
|
|
|
|
Max 20 lines.
|
|
|
|
|
@@ -2651,20 +2811,145 @@ ${allFiles}`;
|
|
|
|
|
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.
|
|
|
|
|
// 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}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
${fixableFiles}`;
|
|
|
|
|
const fixedCode = await kpnRun(agentPrompts.coder.model, fixPrompt, false, 512);
|
|
|
|
|
// Ei ylikirjoiteta Dockerfilea — generoidaan template uudelleen
|
|
|
|
|
if (fixedCode) {
|
|
|
|
|
termLog(` <span style="color:#8b949e">Korjaukset generoitu</span>`);
|
|
|
|
|
}
|
|
|
|
|
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>`);
|
|
|
|
|
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' };
|
|
|
|
|
|
|
|
|
|
const stepsHtml = steps.map((s, i) => {
|
|
|
|
|
const color = agentColors[s.agent] || '#8b949e';
|
|
|
|
|
const icon = s.status === 'done' ? '✓' : '◷';
|
|
|
|
|
const outputPreview = (s.output || '').substring(0, 500);
|
|
|
|
|
return `
|
|
|
|
|
<div style="margin-bottom:16px;border:1px solid #30363d;border-radius:6px;overflow:hidden">
|
|
|
|
|
<div style="background:#161b22;padding:8px 12px;display:flex;justify-content:space-between;align-items:center">
|
|
|
|
|
<span><span style="color:${s.status === 'done' ? '#3fb950' : '#d29922'}">${icon}</span> <strong style="color:${color}">${agentNames[s.agent] || s.agent}</strong> — ${s.label}</span>
|
|
|
|
|
<span style="color:#8b949e;font-size:12px">Vaihe ${i + 1}</span>
|
|
|
|
|
</div>
|
|
|
|
|
${s.input ? `<details><summary style="padding:6px 12px;color:#8b949e;font-size:12px;cursor:pointer">Prompti</summary><pre style="margin:0;padding:8px 12px;background:#010409;font-size:11px;overflow-x:auto;white-space:pre-wrap;color:#8b949e">${s.input.replace(/</g,'<').substring(0, 1000)}</pre></details>` : ''}
|
|
|
|
|
${outputPreview ? `<pre style="margin:0;padding:8px 12px;background:#0d1117;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9">${outputPreview.replace(/</g,'<')}</pre>` : ''}
|
|
|
|
|
</div>`;
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
const filesHtml = fileEntries.map(([name, content]) => `
|
|
|
|
|
<div style="margin-bottom:16px;border:1px solid #30363d;border-radius:6px;overflow:hidden">
|
|
|
|
|
<div style="background:#161b22;padding:8px 12px;font-weight:600;color:#58a6ff">${name}</div>
|
|
|
|
|
<pre style="margin:0;padding:12px;background:#010409;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9">${(content || '').replace(/</g,'<')}</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", sans-serif; background: #0d1117; color: #c9d1d9; padding: 20px; max-width: 900px; margin: 0 auto; }
|
|
|
|
|
h1 { color: #58a6ff; margin-bottom: 4px; }
|
|
|
|
|
h2 { color: #c9d1d9; margin: 24px 0 12px; border-bottom: 1px solid #30363d; padding-bottom: 6px; }
|
|
|
|
|
h3 { color: #8b949e; margin: 16px 0 8px; }
|
|
|
|
|
pre { font-family: 'Courier New', monospace; }
|
|
|
|
|
a { color: #58a6ff; }
|
|
|
|
|
details summary { list-style: none; }
|
|
|
|
|
details summary::-webkit-details-marker { display: none; }
|
|
|
|
|
</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.2 — <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, 100).replace(/</g, '<').replace(/\n/g, ' ');
|
|
|
|
|
var tip = desc + (outPrev ? ' — ' + outPrev : '');
|
|
|
|
|
var icon = st.status === 'done' ? '✓' : '◷';
|
|
|
|
|
if (bi > 0) badges += '<span style="color:#30363d;margin:0 1px">→</span>';
|
|
|
|
|
badges += '<span title="' + tip.replace(/"/g, '"') + '" style="display:inline-block;background:' + bg + ';border:1px solid ' + color + ';border-radius:4px;padding:3px 8px;margin:2px;font-size:11px;color:' + color + ';cursor:help;white-space:nowrap">' + icon + ' ' + st.label.replace(/</g, '<') + '</span>';
|
|
|
|
|
}
|
|
|
|
|
rows += '<div style="display:flex;align-items:center;gap:8px;margin:4px 0"><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">' + badges + '</div></div>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return '<div style="background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:12px">' + rows + '</div>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Yksinkertainen pipeline (vanha: manageri → koodari → testaaja)
|
|
|
|
|
@@ -2983,7 +3268,7 @@ ${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
|
|
|
|
|
'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 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"'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@@ -3394,11 +3679,7 @@ ${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
|
|
|
|
|
while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
|
|
|
|
|
term.scrollTop = term.scrollHeight;
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
// Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
|
|
|
|
|
}
|
|
|
|
|
} else if (isCoder) {
|
|
|
|
|
// Codelab: erillinen addCodeResult-handler käsittelee (rivi 2364)
|
|
|
|
|
@@ -3545,26 +3826,7 @@ ${generatedFiles['Dockerfile'] || '(puuttuu)'}`;
|
|
|
|
|
term.scrollTop = term.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch(e) {}
|
|
|
|
|
|