Files
agentic-studio/network-poc/frontend/src/pages/index.astro
Jaakko Vanhala 7d842529b1 Tarkkailijan raportti: klikkaa avataria → modal + kehäväri arvosanalla
Tarkkailijan vastaus alkaa VERDICT-rivillä:
- GREEN → vihreä kehä → "OK"
- ORANGE → oranssi kehä → "HUOMIOITA"
- RED → punainen kehä → "KRIITTISTÄ"

Kehäväri ja glow jäävät näkyviin pipelinen jälkeen.
Klikkaamalla Tarkkailija-avataria avautuu raportti-modal jossa
README.md renderöidään markdown-muotoiltuna (taulukot, koodi, listat).
Modal sulkeutuu ✕-napista tai klikkaamalla taustaa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:33:11 +03:00

1168 lines
61 KiB
Plaintext

---
import "../styles/global.css";
import StatusBar from "../components/StatusBar.astro";
import Terminal from "../components/Terminal.astro";
import Editor from "../components/Editor.astro";
import Guide from "../components/Guide.astro";
import AgentBar from "../components/AgentBar.astro";
import Settings from "../components/Settings.astro";
---
<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kipinä Agentic Playground</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
window.mermaid = mermaid;
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css">
</head>
<body>
<div class="container">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
<div>
<h1 style="margin-bottom:0"><span style="color:#ff6b00">Kipinä</span> Agentic Playground</h1>
<p style="color:#8b949e;margin:0">AI-ohjelmistokehitystiimi · <span id="hub-version">-</span></p>
</div>
</div>
<!-- Välilehdet -->
<div class="tabs">
<div class="tab active" onclick="switchTab('agents')">Agentit</div>
<div class="tab" onclick="switchTab('editor')">Editor</div>
<div class="tab" onclick="switchTab('guide')">Opas</div>
<div class="tab" onclick="switchTab('settings')">Asetukset</div>
</div>
<!-- Agents-paneeli -->
<div id="panel-agents" class="panel active">
<AgentBar />
<StatusBar />
<Terminal />
</div>
<Editor />
<Guide />
<Settings />
</div>
<script is:inline>
// === Helpers ===
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function highlightCode(code) {
if (typeof hljs !== 'undefined') {
try { return hljs.highlightAuto(code).value; } catch(e) {}
}
return esc(code);
}
// === Globaalit tilat ===
const defaultAgents = {
manager: { name: 'Manageri', avatar: '/avatars/karhunpentu.png', model: 'qwen-coder', order: 0,
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are a senior project manager and software architect. Your job is to plan the file structure of a software project.
RULES:
- List each source file on its own line in format: filename.py: one-line description
- List dependency files FIRST (models.py, schemas.py before main.py)
- Use pyproject.toml for Python dependencies (never requirements.txt)
- Maximum 4-5 files per project
- Only filenames, no directory paths
- Common patterns: models.py → schemas.py → main.py → pyproject.toml
EXAMPLE OUTPUT:
models.py: SQLAlchemy database models and engine setup
schemas.py: Pydantic request/response schemas
main.py: FastAPI application with CRUD endpoints
pyproject.toml: project dependencies` },
coder: { name: 'Koodari', avatar: '/avatars/kipina_notext.png', model: 'qwen-coder', order: 1,
temperature: 0.7, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are an expert Python developer. Write complete, production-ready code.
CRITICAL RULES:
1. Include ALL imports at the top of every file
2. Import from other project files: from models import User, SessionLocal
3. Pydantic schemas use different names than SQLAlchemy models: UserCreate, UserResponse (not User)
4. SQLAlchemy engine: create_engine(url, connect_args={"check_same_thread": False})
5. SessionLocal: sessionmaker(autocommit=False, autoflush=False, bind=engine)
6. FastAPI dependencies: def get_db(): db = SessionLocal(); try: yield db; finally: db.close()
7. Pydantic v2: use model_dump() not dict(), class Config: from_attributes = True
8. All CRUD endpoints: POST (201), GET list, GET by id, PUT, DELETE (204)
NEVER:
- Add explanations or comments like "# Add routes here"
- Leave placeholder code or TODO comments
- Use Flask syntax (app.run) in FastAPI projects
- Forget to import from other project files` },
data: { name: 'Data', avatar: '/avatars/pesukarhu_notext.png', model: 'qwen-coder', order: 2,
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are a database architect specializing in SQLAlchemy and relational databases.
YOUR RESPONSIBILITIES:
1. Design normalized database schemas with proper column types and constraints
2. Define SQLAlchemy models with __tablename__, primary keys, indexes, and relationships
3. Set up engine, SessionLocal, and Base in the same file (models.py or database.py)
4. Use String(length) not bare String for SQLite compatibility
5. Add nullable=False for required fields, unique=True where appropriate
6. Use Column(Integer, primary_key=True, index=True) for IDs
ALWAYS INCLUDE:
- from sqlalchemy import create_engine, Column, Integer, String
- from sqlalchemy.ext.declarative import declarative_base
- from sqlalchemy.orm import sessionmaker
- DATABASE_URL, engine, SessionLocal, Base` },
qa: { name: 'QA', avatar: '/avatars/susi_notext.png', model: 'qwen-coder', order: 3,
temperature: 0.4, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are a QA engineer writing automated tests.
WRITE TESTS USING:
- pytest as the test framework
- FastAPI TestClient for API endpoint testing
- SQLAlchemy in-memory SQLite for test database isolation
TEST STRUCTURE:
1. test_create: POST valid data → 201, verify response matches input
2. test_list: GET collection → 200, verify array response
3. test_get_by_id: GET with valid/invalid ID → 200/404
4. test_update: PUT with valid data → 200, verify changes persisted
5. test_delete: DELETE → 204, verify GET returns 404 after
ALWAYS: from fastapi.testclient import TestClient` },
tester: { name: 'DevOps', avatar: '/avatars/laiskiainen_notext.png', model: 'qwen-coder', order: 4,
temperature: 0.3, topK: 40, repeatPenalty: 1.1, maxTokens: 512,
prompt: `You are a strict code reviewer. Review the provided code and check for these issues:
CHECKLIST:
1. ✓ All imports exist (no missing "from X import Y")
2. ✓ Import names match: if models.py exports "User", main.py imports "User" (not "UserModel")
3. ✓ Pydantic schema names don't conflict with SQLAlchemy model names
4. ✓ All CRUD endpoints have error handling (404 for not found)
5. ✓ Database session is properly closed (get_db with yield + finally)
6. ✓ Response models are specified for type safety
7. ✓ No placeholder comments like "# Add routes here"
RESPOND:
- If all checks pass: "LGTM"
- If issues found: list each as "ISSUE: filename.py: description"
- Be specific and actionable, not vague` },
observer: { name: 'Tarkkailija', avatar: '/avatars/aikuinen_susi.png', model: 'qwen-coder', order: 5,
temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
prompt: `You are an independent technical observer and risk analyst.
EVALUATE THE PROJECT FOR:
1. ARCHITECTURE: Is the file structure logical? Are responsibilities separated?
2. SECURITY: SQL injection risks? Input validation? Authentication?
3. RELIABILITY: Error handling? Database connection management? Edge cases?
4. MAINTAINABILITY: Consistent naming? Clear code structure? Would a new developer understand this?
OUTPUT FORMAT:
- RISK: [critical/high/medium/low] Description
- List max 3-5 most important findings
- End with overall assessment: "SHIP IT" or "NEEDS WORK: reason"` },
};
// Versio: kasvata kun oletuspromptit muuttuvat
const AGENTS_VERSION = 2;
let agents;
const savedVersion = parseInt(localStorage.getItem('kpn-agents-version') || '0');
if (savedVersion < AGENTS_VERSION && localStorage.getItem('kpn-agents')) {
// Uudet oletukset saatavilla — kysytään käyttäjältä
if (confirm('Agenttien oletuspromptit on päivitetty. Haluatko ottaa uudet käyttöön?\n\n(OK = päivitä oletuksiin, Peruuta = säilytä omat muokkauksesi)')) {
agents = JSON.parse(JSON.stringify(defaultAgents));
localStorage.setItem('kpn-agents', JSON.stringify(agents));
} else {
agents = JSON.parse(localStorage.getItem('kpn-agents'));
}
localStorage.setItem('kpn-agents-version', String(AGENTS_VERSION));
} else {
agents = JSON.parse(localStorage.getItem('kpn-agents') || 'null') || JSON.parse(JSON.stringify(defaultAgents));
localStorage.setItem('kpn-agents-version', String(AGENTS_VERSION));
}
function saveAgents() { localStorage.setItem('kpn-agents', JSON.stringify(agents)); }
function getAgentModel(name) { const a = agents[name]; return a ? a.model : name; }
// LLM-asetukset (localStorage-persistenssi)
const defaultSettings = {
systemPrompt: "You are a coding assistant. Respond with ONLY code. No explanations, no markdown fences, no 'Please note' text. Only working code with proper imports.",
temperature: 0.7,
topK: 40,
repeatPenalty: 1.15,
maxTokens: 1024,
stopSequences: "\\n###\\n\\nExplanation\\nNote:\\nPlease note\\nThis is a basic\\n```\\n\\n\\n// Example\\n# Example",
model: "qwen2.5-coder:3b",
};
let settings = JSON.parse(localStorage.getItem('kpn-settings') || 'null') || { ...defaultSettings };
function saveSettings() { localStorage.setItem('kpn-settings', JSON.stringify(settings)); }
// === Tab switching ===
window.switchTab = function(tab) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('panel-' + tab)?.classList.add('active');
document.querySelector(`.tab[onclick*="${tab}"]`)?.classList.add('active');
window.location.hash = tab;
if (tab === 'editor') initMonaco();
};
const initHash = window.location.hash.replace('#','');
if (['editor','guide'].includes(initHash)) switchTab(initHash);
// === Agent selection & config ===
let selectedAgent = null;
// Highlightaa aktiivinen agentti pipeline-aikana
function highlightAgent(agentKey) {
document.querySelectorAll('.agent-avatar').forEach(el => el.classList.remove('active'));
if (agentKey) document.querySelector(`.agent-avatar[data-agent="${agentKey}"]`)?.classList.add('active');
}
function renderAgentBar() {
const bar = document.getElementById('agent-bar');
const sorted = Object.entries(agents).sort((a,b) => (a[1].order||0) - (b[1].order||0));
bar.innerHTML = sorted.map(([key, a]) =>
`<div class="agent-avatar${selectedAgent===key?' active':''}" data-agent="${key}" onclick="selectAgent('${key}')" draggable="true" ondragstart="dragAgent(event,'${key}')" ondragover="event.preventDefault()" ondrop="dropAgent(event,'${key}')" title="${esc(a.name)}">` +
`<img src="${a.avatar}" alt="${esc(a.name)}">` +
`<div class="avatar-name">${esc(a.name)}</div></div>`
).join('');
}
renderAgentBar();
window.selectAgent = function(key) {
selectedAgent = (selectedAgent === key) ? null : key; // Toggle
renderAgentBar();
const config = document.getElementById('agent-config');
if (!selectedAgent) { config.style.display = 'none'; return; }
// Tarkkailija-klikkaus: avaa raportti modalina jos se on generoitu
if (key === 'observer' && window._lastReport) {
config.style.display = 'none';
showReportModal(window._lastReport);
selectedAgent = null;
renderAgentBar();
return;
}
const a = agents[key];
config.style.display = 'block';
document.getElementById('config-avatar').src = a.avatar;
document.getElementById('config-name').value = a.name;
document.getElementById('config-role').textContent = key;
document.getElementById('config-model').value = a.model;
document.getElementById('config-prompt').value = a.prompt || '';
// Sampling-parametrit
const tempEl = document.getElementById('config-temperature');
const tempValEl = document.getElementById('config-temp-val');
const maxtokEl = document.getElementById('config-maxtokens');
const maxtokValEl = document.getElementById('config-maxtok-val');
const topkEl = document.getElementById('config-topk');
const topkValEl = document.getElementById('config-topk-val');
const repEl = document.getElementById('config-repeat');
const repValEl = document.getElementById('config-rep-val');
tempEl.value = a.temperature ?? 0.7; tempValEl.textContent = tempEl.value;
maxtokEl.value = a.maxTokens ?? 1024; maxtokValEl.textContent = maxtokEl.value;
topkEl.value = a.topK ?? 40; topkValEl.textContent = topkEl.value;
repEl.value = a.repeatPenalty ?? 1.15; repValEl.textContent = repEl.value;
// Pipeline-järjestys
const pipeline = document.getElementById('config-pipeline');
const sorted = Object.entries(agents).sort((a,b) => (a[1].order||0) - (b[1].order||0));
pipeline.innerHTML = sorted.map(([k,ag]) =>
`<span style="padding:3px 8px;border-radius:4px;font-size:11px;border:1px solid ${k===key?'var(--accent)':'var(--border)'};color:${k===key?'var(--accent)':'#8b949e'};cursor:grab" draggable="true" ondragstart="dragAgent(event,'${k}')" ondragover="event.preventDefault()" ondrop="dropAgent(event,'${k}')">${ag.name}</span>`
).join('');
// Muutosten tallennus
document.getElementById('config-name').oninput = () => { agents[key].name = document.getElementById('config-name').value; saveAgents(); renderAgentBar(); };
document.getElementById('config-model').onchange = () => { agents[key].model = document.getElementById('config-model').value; saveAgents(); };
document.getElementById('config-prompt').oninput = () => { agents[key].prompt = document.getElementById('config-prompt').value; saveAgents(); };
tempEl.oninput = () => { agents[key].temperature = +tempEl.value; tempValEl.textContent = tempEl.value; saveAgents(); };
maxtokEl.oninput = () => { agents[key].maxTokens = +maxtokEl.value; maxtokValEl.textContent = maxtokEl.value; saveAgents(); };
topkEl.oninput = () => { agents[key].topK = +topkEl.value; topkValEl.textContent = topkEl.value; saveAgents(); };
repEl.oninput = () => { agents[key].repeatPenalty = +repEl.value; repValEl.textContent = repEl.value; saveAgents(); };
};
window.closeAgentConfig = function() { selectedAgent = null; document.getElementById('agent-config').style.display = 'none'; renderAgentBar(); };
// Drag & drop järjestys
let dragKey = null;
window.dragAgent = function(e, key) { dragKey = key; e.dataTransfer.effectAllowed = 'move'; };
window.dropAgent = function(e, targetKey) {
e.preventDefault();
if (!dragKey || dragKey === targetKey) return;
const srcOrder = agents[dragKey].order;
const tgtOrder = agents[targetKey].order;
agents[dragKey].order = tgtOrder;
agents[targetKey].order = srcOrder;
saveAgents(); renderAgentBar();
if (selectedAgent) selectAgent(selectedAgent); // Päivitä pipeline-näkymä
};
// Uuden agentin luonti
window.addCustomAgent = function() {
const id = 'custom_' + Date.now();
const avatars = ['/avatars/bear.png','/avatars/beaver.png','/avatars/gecko.png','/avatars/lion.png','/avatars/penguin.png','/avatars/spider.png','/avatars/walrus.png','/avatars/serpent.png'];
agents[id] = {
name: 'Uusi agentti',
avatar: avatars[Math.floor(Math.random() * avatars.length)],
model: 'qwen-coder',
prompt: '',
order: Object.keys(agents).length,
};
saveAgents(); renderAgentBar(); selectAgent(id);
};
// Agentin poisto
window.deleteAgent = function() {
if (!selectedAgent || !agents[selectedAgent]) return;
if (!confirm(`Poistetaanko ${agents[selectedAgent].name}?`)) return;
delete agents[selectedAgent];
saveAgents(); selectedAgent = null;
document.getElementById('agent-config').style.display = 'none';
renderAgentBar();
};
// === WebSocket ===
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
const uiSocket = new WebSocket(wsUrl);
window._uiSocket = uiSocket;
uiSocket.onopen = async () => {
document.getElementById('hub-dot').style.background = '#3fb950';
document.getElementById('hub-label').textContent = 'Yhdistetty';
document.getElementById('hub-label').style.color = '#3fb950';
// Rekisteröidy viewerina
uiSocket.send(JSON.stringify({
type: 'auth', status: 'viewer', node_type: 'browser',
platform: navigator.platform || '', cpu_cores: navigator.hardwareConcurrency || 0,
device_memory_gb: navigator.deviceMemory || 0, allocated_gb: 0, selected_task: 'viewer',
}));
// Tarkistetaan onko natiivisolmu jo hubissa
try {
const res = await fetch('/api/v1/chat/completions', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ model: 'qwen-coder', prompt: 'ping', task_id: 'status-check' }),
signal: AbortSignal.timeout(3000),
});
if (res.status !== 503) {
// Solmu löytyi (natiivi tai Wasm)
document.getElementById('compute-dot').style.background = '#3fb950';
document.getElementById('compute-label').textContent = 'Valmis (natiivi)';
document.getElementById('compute-label').style.color = '#3fb950';
document.getElementById('compute-btn').textContent = '✓ Valmis';
document.getElementById('compute-btn').className = 'btn btn-green';
llmReady = true;
}
} catch(e) {
// Timeout = solmu on olemassa mutta laskee — se on ok
document.getElementById('compute-dot').style.background = '#3fb950';
document.getElementById('compute-label').textContent = 'Valmis (natiivi)';
document.getElementById('compute-label').style.color = '#3fb950';
document.getElementById('compute-btn').textContent = '✓ Valmis';
document.getElementById('compute-btn').className = 'btn btn-green';
llmReady = true;
}
};
uiSocket.onclose = () => {
document.getElementById('hub-dot').style.background = '#f85149';
document.getElementById('hub-label').textContent = 'Yhteys katkennut';
document.getElementById('hub-label').style.color = '#f85149';
};
// === Terminal ===
const termPanel = document.getElementById('terminal');
const termInput = document.getElementById('term-input');
const termHistory = [];
let termHistIdx = -1;
function termLog(html, color) {
const div = document.createElement('div');
div.className = 'terminal-line';
if (color) div.style.color = color;
div.innerHTML = html;
termPanel.appendChild(div);
while (termPanel.children.length > 100 && !termPanel.firstChild.querySelector('.stream-content')) termPanel.removeChild(termPanel.firstChild);
termPanel.scrollTop = termPanel.scrollHeight;
}
// === Wasm inference (main thread) ===
let wasmReady = false;
let wasmNodeStarted = false;
let llmReady = false;
async function ensureWasm() {
if (wasmReady) return;
const { default: init, start_agent_node, set_gpu_load } = await import('/pkg/node.js');
window._wasmExports = { init, start_agent_node, set_gpu_load };
termLog(' Ladataan WebAssembly...', '#d29922');
await init();
wasmReady = true;
termLog(' <span style="color:#3fb950">✓</span> WebAssembly valmis');
}
async function ensureNode() {
if (wasmNodeStarted) return;
await ensureWasm();
const { start_agent_node } = window._wasmExports;
const deviceInfo = JSON.stringify({
allocated_gb: 4,
cpu_cores: navigator.hardwareConcurrency || 0,
device_memory_gb: navigator.deviceMemory || 0,
platform: navigator.platform || '',
gpu: null,
selected_task: 'qwen-coder-05b'
});
termLog(' Yhdistetään laskentasolmuna...', '#d29922');
await start_agent_node(wsUrl, false, deviceInfo, 4);
wasmNodeStarted = true;
// Odotetaan WS-yhteyden avautumista (kuunnellaan console.log)
await new Promise(resolve => {
const origLog = console.log;
const check = (...args) => {
const msg = args.join(' ');
if (msg.includes('Yhteys Hubiin avattu')) {
console.log = origLog;
resolve();
}
};
console.log = function(...args) { origLog.apply(console, args); check(...args); };
// Timeout 15s
setTimeout(() => { console.log = origLog; resolve(); }, 15000);
});
document.getElementById('compute-dot').style.background = '#d29922';
document.getElementById('compute-label').textContent = 'Yhdistetty';
document.getElementById('compute-label').style.color = '#d29922';
termLog(' <span style="color:#3fb950">✓</span> Laskentasolmu yhdistetty hubiin');
}
// Kuunnellaan console.log mallin latauksen etenemiselle
const _origLog = console.log;
console.log = function(...args) {
_origLog.apply(console, args);
const msg = args.join(' ');
if (msg.includes('[Coder]') && msg.includes('Malli ladattu')) {
llmReady = true;
document.getElementById('compute-dot').style.background = '#3fb950';
document.getElementById('compute-label').textContent = 'Qwen2.5-Coder:0.5B';
document.getElementById('compute-label').style.color = '#3fb950';
const btn = document.getElementById('compute-btn');
if (btn) { btn.textContent = '✓ Valmis'; btn.className = 'btn btn-green'; }
localStorage.setItem('kpn-coder-loaded', 'true');
}
};
// Compute-nappi
document.getElementById('compute-btn')?.addEventListener('click', () => {
const btn = document.getElementById('compute-btn');
if (btn.textContent.includes('Valmis')) return;
btn.textContent = 'Ladataan...';
btn.className = 'btn btn-muted';
ensureNode();
});
// Wasm-autostart vain jos natiivisolmua ei löydy (tarkistetaan onopen:ssa)
// === kpnRun: lähettää promptin mallille ===
const activeStreams = {};
async function kpnRun(model, prompt, silent) {
const taskId = crypto.randomUUID();
const statusDiv = document.createElement('div');
statusDiv.className = 'terminal-line';
statusDiv.id = 'status-' + taskId;
statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:var(--accent)">${model}</span> käsittelee...</span>`;
termPanel.appendChild(statusDiv);
termPanel.scrollTop = termPanel.scrollHeight;
try {
// Ei odotetaan Wasmia — lähetetään suoraan hubille.
// Jos hub löytää natiivisolmun, vastaus tulee nopeasti.
// Jos 503, käynnistetään Wasm-fallback.
if (!silent) {
const streamDiv = document.createElement('div');
streamDiv.className = 'terminal-line';
streamDiv.innerHTML = ' <span class="stream-content"></span><span style="color:#8b949e;animation:blink 1s infinite">▌</span>';
termPanel.appendChild(streamDiv);
termPanel.scrollTop = termPanel.scrollHeight;
activeStreams[taskId] = streamDiv;
}
statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:var(--accent)">${model}</span> käsittelee...</span>`;
const res = await fetch('/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, task_id: taskId }),
});
if (res.status === 503 && !wasmNodeStarted) {
// Ei natiivisolmua — käynnistetään Wasm-fallback
statusDiv.innerHTML = ' <span style="color:#d29922">→ Ei natiivisolmua — käynnistetään selainlaskenta...</span>';
await ensureNode();
for (let i = 0; i < 120 && !llmReady; i++) await new Promise(r => setTimeout(r, 500));
if (!llmReady) { statusDiv.innerHTML = ' <span style="color:#f85149">✗ Mallin lataus aikakatkaistiin</span>'; return null; }
// Yritetään uudelleen
return kpnRun(model, prompt, silent);
}
if (!res.ok) {
const err = await res.text().catch(() => res.statusText);
statusDiv.innerHTML = ` <span style="color:#f85149">✗ ${esc(err)}</span>`;
return null;
}
const data = await res.json();
const response = (data.response || '').trim();
const tokGen = data.tokens_generated || 0;
const durS = data.duration_ms ? (data.duration_ms / 1000).toFixed(1) + 's' : '';
const tokS = data.tokens_per_sec ? data.tokens_per_sec.toFixed(1) + ' tok/s' : '';
statusDiv.innerHTML = ` <span style="color:#3fb950">✓</span> <span style="color:var(--accent)">${esc(data.model || model)}</span> <span style="color:#8b949e">${tokGen} tok · ${durS} · ${tokS}</span>`;
if (!silent && response) {
const firstLine = response.split('\n').find(l => l.trim()) || response;
const lineCount = response.split('\n').filter(l => l.trim()).length;
const uid = 'code-' + Date.now();
termLog(
` <span style="color:#3fb950;cursor:pointer" onclick="document.getElementById('${uid}').style.display=document.getElementById('${uid}').style.display==='none'?'block':'none'">`
+ `<span style="color:#8b949e">▶</span> ${esc(firstLine.trim())} <span style="color:#8b949e">${lineCount > 1 ? '(+' + (lineCount-1) + ' riviä)' : ''}</span></span>`
+ `<pre id="${uid}" style="display:none;margin:4px 0 0 16px;font:inherit;white-space:pre-wrap;border-left:2px solid var(--border);padding-left:10px">${highlightCode(response)}</pre>`
);
}
return response;
} catch(e) {
statusDiv.innerHTML = ` <span style="color:#f85149">✗ ${esc(e.message)}</span>`;
return null;
} finally {
if (activeStreams[taskId]) { activeStreams[taskId].remove(); delete activeStreams[taskId]; }
}
}
// === WebSocket message handler ===
uiSocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'stats') {
document.getElementById('hub-version').textContent = 'v' + (data.version || '?');
} else if (data.type === 'task_routed') {
const statusDiv = document.getElementById('status-' + data.task_id);
if (statusDiv) {
const color = data.status === 'queued' ? '#d29922' : '#8b949e';
statusDiv.innerHTML = ` <span style="color:${color}">${data.status === 'queued' ? '⏳' : '→'} ${esc(data.message)}</span>`;
}
} else if (data.type === 'llm_chunk' && data.task_id && activeStreams[data.task_id]) {
const el = activeStreams[data.task_id].querySelector('.stream-content');
if (el) { el.textContent += data.token || ''; termPanel.scrollTop = termPanel.scrollHeight; }
}
} catch(e) {}
};
// === Terminal commands ===
const kpnCommands = {
'kpn': ['help','run','project','pipeline','load','status','models','clear'],
'kpn run': ['coder','coder-3b','manager','tester','qa','qwen-coder','smollm-135m'],
'kpn load': ['1','2'],
'kpn project': ['"'],
'kpn pipeline': ['"'],
};
const kpnExamples = {
'kpn run coder': ['"hello world in python"','"fibonacci in rust"','"quicksort in javascript"'],
'kpn run coder-3b': ['"REST API with Flask"','"binary search tree"'],
'kpn project': ['"FastAPI + SQLite REST API"','"CLI tool for CSV processing"'],
'kpn pipeline': ['"todo-sovellus"','"laskin pythonilla"'],
};
// Tab completion
function getCompletions(val) {
const words = val.trimEnd().split(/\s+/);
for (let d = words.length; d >= 1; d--) {
const prefix = words.slice(0,d).join(' ');
const partial = words[d] || '';
if (kpnExamples[prefix] && !partial) return { items: kpnExamples[prefix], prefix: prefix + ' ' };
const cands = kpnCommands[prefix];
if (cands) {
const m = partial ? cands.filter(c => c.startsWith(partial)) : cands;
if (m.length > 0) return { items: m, prefix: prefix + ' ' };
}
}
if (!val.trim()) return { items: kpnCommands['kpn'] || [], prefix: 'kpn ' };
return { items: [], prefix: val };
}
// Dropdown
const dropdown = document.getElementById('term-dropdown');
let ddItems = [], ddIdx = -1, ddPrefix = '';
function showDD(items, prefix) {
if (!items.length) { hideDD(); return; }
ddItems = items; ddPrefix = prefix; ddIdx = -1;
dropdown.innerHTML = items.map((item,i) =>
`<div class="dd-item" data-i="${i}" onclick="selectDD()">${esc(item)}</div>`
).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.dd-item').forEach(el => {
el.addEventListener('mouseenter', () => highlightDD(+el.dataset.i));
});
}
function hideDD() { dropdown.style.display = 'none'; ddItems = []; ddIdx = -1; }
function highlightDD(i) {
ddIdx = i;
dropdown.querySelectorAll('.dd-item').forEach((el,j) => el.classList.toggle('active', j===i));
dropdown.children[i]?.scrollIntoView({ block: 'nearest' });
}
window.selectDD = function() {
if (ddIdx >= 0 && ddIdx < ddItems.length)
termInput.value = ddPrefix + ddItems[ddIdx] + (ddItems[ddIdx].startsWith('"') ? '' : ' ');
hideDD(); termInput.focus();
};
// Agenttiprompti-mapping
const agentModels = new Proxy({}, { get: (_, key) => getAgentModel(key) });
function termExec(cmd) {
termLog(`<span class="terminal-prompt">$</span> ${esc(cmd)}`);
termHistory.unshift(cmd); termHistIdx = -1;
const parts = cmd.trim().split(/\s+/);
if (parts[0] !== 'kpn') { termLog(' Tuntematon komento. Kokeile: kpn help', '#f85149'); return; }
const sub = parts[1];
if (sub === 'help' || !sub) {
termLog(' kpn run &lt;malli&gt; "prompti" — aja tehtävä', '#a5d6ff');
termLog(' kpn project "kuvaus" — monivaiheinen projekti', '#a5d6ff');
termLog(' kpn pipeline "tehtävä" — nopea: manageri→koodari→testaaja', '#a5d6ff');
termLog(' kpn load — lataa kielimalli', '#a5d6ff');
termLog(' kpn models — mallit', '#a5d6ff');
termLog(' kpn status — verkon tila', '#a5d6ff');
termLog(' kpn clear — tyhjennä', '#a5d6ff');
} else if (sub === 'clear') { termPanel.innerHTML = '';
} else if (sub === 'load') {
const btn = document.getElementById('compute-btn');
if (btn && btn.textContent.includes('Valmis')) { termLog(' ✓ Malli jo ladattu', '#3fb950'); }
else { btn?.click(); }
} else if (sub === 'models') {
termLog(' <span style="color:var(--accent)">1</span> qwen-coder Qwen2.5-Coder:0.5B <span style="color:#8b949e">~990 MB</span>');
termLog(' <span style="color:var(--accent)">2</span> qwen-coder-3b Qwen2.5-Coder:3B <span style="color:#8b949e">~6.2 GB</span>');
termLog(' <span style="color:var(--accent)">3</span> smollm-135m SmolLM 135M <span style="color:#8b949e">~270 MB</span>');
} else if (sub === 'status') {
termLog(` Hub: ${document.getElementById('hub-label').textContent} | Laskenta: ${document.getElementById('compute-label').textContent}`, '#a5d6ff');
} else if (sub === 'run') {
let model = parts[2];
const after = cmd.replace(/^kpn\s+run\s+\S+\s*/, '');
const m = after.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
const prompt = (m && (m[1]||m[2]||m[3]||'')).trim();
if (!model || !prompt) { termLog(' Käyttö: kpn run &lt;malli&gt; "prompti"', '#f85149'); return; }
if (agentModels[model]) model = agentModels[model];
kpnRun(model, prompt);
} else if (sub === 'project') {
const after = cmd.replace(/^kpn\s+project\s*/, '');
const m = after.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
const task = (m && (m[1]||m[2]||m[3]||'')).trim();
if (!task) { termLog(' Käyttö: kpn project "kuvaus"', '#f85149'); return; }
kpnProject(task);
} else if (sub === 'pipeline') {
const after = cmd.replace(/^kpn\s+pipeline\s*/, '');
const m = after.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
const task = (m && (m[1]||m[2]||m[3]||'')).trim();
if (!task) { termLog(' Käyttö: kpn pipeline "tehtävä"', '#f85149'); return; }
kpnPipelineSimple(task);
} else { termLog(` Tuntematon: ${sub}. Kokeile: kpn help`, '#f85149'); }
}
// Input handler
termInput?.addEventListener('keydown', (e) => {
if (dropdown.style.display === 'block') {
if (e.key === 'ArrowDown') { e.preventDefault(); highlightDD(Math.min(ddIdx+1, ddItems.length-1)); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); highlightDD(Math.max(ddIdx-1, 0)); return; }
if ((e.key === 'Enter' || e.key === 'Tab') && ddIdx >= 0) { e.preventDefault(); selectDD(); return; }
if (e.key === 'Escape') { e.preventDefault(); hideDD(); return; }
}
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault(); hideDD();
const val = termInput.value.trimEnd();
if (!val) return;
const qm = val.match(/^(.+\s)".*"?$|^(.+\s)'.*'?$/);
if (qm) termInput.value = (qm[1]||qm[2]).trimEnd() + ' ';
else { const ls = val.lastIndexOf(' '); termInput.value = ls > 0 ? val.substring(0, ls+1) : ''; }
} else if (e.key === 'Tab') {
e.preventDefault();
const { items, prefix } = getCompletions(termInput.value);
if (items.length === 1) { termInput.value = prefix + items[0] + (items[0].startsWith('"') ? '' : ' '); hideDD(); }
else if (items.length > 1) showDD(items, prefix);
} else if (e.key === 'Enter') {
hideDD();
const cmd = termInput.value.trim();
if (cmd) termExec(cmd);
termInput.value = '';
} else if (e.key === 'ArrowUp') { e.preventDefault(); if (termHistIdx < termHistory.length-1) { termHistIdx++; termInput.value = termHistory[termHistIdx]; }
} else if (e.key === 'ArrowDown') { e.preventDefault(); if (termHistIdx > 0) { termHistIdx--; termInput.value = termHistory[termHistIdx]; } else { termHistIdx=-1; termInput.value=''; }
}
});
termPanel?.addEventListener('click', () => termInput?.focus());
document.addEventListener('click', (e) => { if (!termInput?.contains(e.target) && !dropdown?.contains(e.target)) hideDD(); });
// === Template-pohjainen projektipipeline ===
let templates = {};
// Ladataan mallipohjat
(async () => {
try {
const res = await fetch('/templates/fastapi-crud.json');
if (res.ok) { const t = await res.json(); templates[t.name] = t; }
} catch(e) {}
})();
function explainStep(title, explanation) {
termLog(`\n <span style="color:#a371f7;font-size:12px">💡 ${esc(title)}</span>`);
termLog(` <span style="color:#8b949e;font-size:12px">${esc(explanation)}</span>`);
}
async function kpnProject(task) {
const cdr = agents.coder || Object.values(agents)[1];
const tst = agents.tester || Object.values(agents)[2];
// Etsitään sopivin mallipohja
const template = Object.values(templates)[0]; // Toistaiseksi vain FastAPI CRUD
if (!template) {
termLog(' ✗ Mallipohjia ei ladattu', '#f85149');
return;
}
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ ${esc(template.name)} — ${esc(task)} ━━━</span>`);
explainStep('Mallipohja', `Käytetään "${template.name}" -mallipohjaa jossa ${template.order.length} tiedostoa: ${template.order.join(', ')}. Jokainen tiedosto generoidaan järjestyksessä, ja aiemmat tiedostot annetaan kontekstina seuraavalle.`);
const files = {};
for (let i = 0; i < template.order.length; i++) {
const fileName = template.order[i];
const fileDef = template.files[fileName];
if (!fileDef) continue;
const step = i + 1;
// Valitaan oikea agentti tiedostotyypin mukaan
const isDbFile = fileName === 'models.py' || fileName === 'database.py';
const dataAgent = agents.data || Object.values(agents)[2];
const fileAgent = isDbFile && dataAgent ? dataAgent : cdr;
const fileAgentKey = isDbFile && dataAgent ? 'data' : 'coder';
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step}/${template.order.length}] ${esc(fileAgent.name)}</span> — ${esc(fileName)}`);
highlightAgent(fileAgentKey);
// Opettava selitys: miksi tämä tiedosto, mitä se sisältää
explainStep(fileName, fileDef.instructions);
// Rakennetaan prompti: esimerkki + konteksti + ohje
let prompt = '';
// Agentin system prompt (data-agentti models.py:lle, koodari muille)
if (fileAgent.prompt) prompt += fileAgent.prompt + '\n\n';
// Esimerkki (few-shot)
prompt += `EXAMPLE of ${fileName} (for a different project, adapt to this one):\n`;
prompt += '```\n' + fileDef.example + '\n```\n\n';
// Aiemmin generoidut tiedostot (konteksti)
const prevFiles = Object.entries(files);
if (prevFiles.length > 0) {
prompt += 'Already written files in THIS project:\n';
for (const [name, code] of prevFiles) {
prompt += `--- ${name} ---\n${code}\n\n`;
}
}
// Tehtävä
prompt += `NOW write "${fileName}" for THIS project: ${task}\n`;
prompt += fileDef.instructions + '\n';
prompt += 'Adapt the example to match the project description. Import from already written files. Write ONLY the code, no explanations.';
const code = await kpnRun(fileAgent.model, prompt);
if (!code) {
termLog(` ✗ Keskeytyi (${fileName})`, '#f85149');
return;
}
files[fileName] = code;
}
const allCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
let stepN = template.order.length + 1;
// DevOps/Testaaja: koodikatselmointi
const tst = agents.tester || Object.values(agents)[4];
termLog(`\n<span style="color:var(--accent);font-weight:bold">[${stepN}] ${esc(tst.name)}</span> — koodikatselmointi`);
highlightAgent('tester');
explainStep('Koodikatselmointi', `${tst.name} tarkistaa importit, nimeämiset, virheenkäsittelyn ja tiedostojen yhteensopivuuden.`);
const tstPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') + `Review this project:\n\n${allCode}`;
const review = await kpnRun(tst.model, tstPrompt);
stepN++;
// Korjausluuppi (jos tarpeen)
if (review && !review.toLowerCase().includes('lgtm')) {
termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepN}] ${esc(cdr.name)}</span> — korjaukset`);
highlightAgent('coder');
explainStep('Korjausluuppi', `${tst.name} löysi ongelmia. ${cdr.name} saa palautteen ja korjaa koodin.`);
await kpnRun(cdr.model, `${cdr.prompt ? cdr.prompt+'\n\n' : ''}Fix these issues:\n${review}\n\nCurrent code:\n${allCode}\n\nWrite the corrected files.`);
stepN++;
}
// QA: testit
const qaAgent = agents.qa || Object.values(agents)[3];
if (qaAgent) {
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — testit`);
highlightAgent('qa');
explainStep('Testit', `${qaAgent.name} kirjoittaa pytest-testit kaikille endpointeille.`);
const qaPrompt = (qaAgent.prompt ? qaAgent.prompt+'\n\n' : '') + `Write pytest tests for this project:\n\n${allCode}\n\nWrite a complete test_main.py file with TestClient.`;
const tests = await kpnRun(qaAgent.model, qaPrompt);
if (tests) files['test_main.py'] = tests;
stepN++;
}
// DevOps: Dockerfile
termLog(`\n<span style="color:var(--accent);font-weight:bold">[${stepN}] ${esc(tst.name)}</span> — Dockerfile`);
highlightAgent('tester');
explainStep('Dockerfile', `${tst.name} generoi Docker-kontin joka pakkaa projektin ajettavaksi.`);
const dockerPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') +
`Write a Dockerfile for this Python FastAPI project.\n\n` +
`Project files: ${Object.keys(files).join(', ')}\n\n` +
`Requirements:\n` +
`- Use python:3.12-slim as base\n` +
`- Install uv: COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv\n` +
`- Copy pyproject.toml first, then uv sync, then copy source\n` +
`- Expose port 8000\n` +
`- CMD: uv run uvicorn main:app --host 0.0.0.0 --port 8000\n` +
`\nWrite ONLY the Dockerfile, no explanations.`;
const dockerfile = await kpnRun(tst.model, dockerPrompt);
if (dockerfile) files['Dockerfile'] = dockerfile;
stepN++;
// Tarkkailija: yhteenveto + raportti + arvosana
const obs = agents.observer || Object.values(agents)[5];
if (obs) {
termLog(`\n<span style="color:#8b949e;font-weight:bold">[${stepN}] ${esc(obs.name)}</span> — projektin yhteenveto`);
highlightAgent('observer');
explainStep('Raportti', `${obs.name} kokoaa yhteenvedon ja antaa arvosanan.`);
const finalCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
const obsPrompt = (obs.prompt ? obs.prompt+'\n\n' : '') +
`Write a project README.md report in markdown for: ${task}\n\n` +
`IMPORTANT: Start the FIRST LINE with exactly one of these verdicts:\n` +
`VERDICT: GREEN — project is production-ready, no issues\n` +
`VERDICT: ORANGE — project works but has warnings or improvements needed\n` +
`VERDICT: RED — project has critical issues that must be fixed\n\n` +
`Then include:\n` +
`# Project: ${task}\n` +
`## Files\n## How to run\n## API Endpoints\n## Architecture\n## Risk assessment\n\n` +
`Project code:\n${finalCode}`;
const readme = await kpnRun(obs.model, obsPrompt);
if (readme) {
files['README.md'] = readme;
// Tallennetaan raportti globaalisti jotta tarkkailija-klikkaus avaa sen
window._lastReport = readme;
// Parsitaan arvosana → tarkkailijan kehäväri
const firstLine = readme.split('\n')[0].toUpperCase();
let verdictColor = '#3fb950'; // oletus: vihreä
let verdictText = 'OK';
if (firstLine.includes('RED')) { verdictColor = '#f85149'; verdictText = 'KRIITTISTÄ'; }
else if (firstLine.includes('ORANGE')) { verdictColor = '#d29922'; verdictText = 'HUOMIOITA'; }
// Asetetaan tarkkailijan kehäväri
const obsAvatar = document.querySelector('.agent-avatar[data-agent="observer"] img');
if (obsAvatar) {
obsAvatar.style.borderColor = verdictColor;
obsAvatar.style.boxShadow = `0 0 12px ${verdictColor}`;
}
termLog(` <span style="color:${verdictColor};font-weight:bold">● ${verdictText}</span> — klikkaa Tarkkailijaa nähdäksesi raportin`);
}
stepN++;
}
// Pipeline valmis
highlightAgent(null);
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis (${Object.keys(files).length} tiedostoa) ━━━</span>`);
explainStep('Tulos', `Projekti "${task}" generoitu ${Object.keys(files).length} tiedostoon. Klikkaa "Avaa editorissa" tutkiaksesi koodia ja README:tä.`);
renderProjectCard(files, task);
}
async function kpnPipelineSimple(task) {
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ Pipeline ━━━</span>`);
termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span>`);
const plan = await kpnRun('qwen-coder', `Analyse briefly, write a spec:\n${task}`);
if (!plan) return;
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span>`);
const code = await kpnRun('qwen-coder', `${plan}\n\nWrite the code.`);
if (!code) return;
termLog(`\n<span style="color:var(--accent);font-weight:bold">[3/3] Testaaja</span>`);
await kpnRun('qwen-coder', `Review briefly:\n${code}`);
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis ━━━</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-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;
}
window.copyProjectFile = function(id, name) {
const files = window._projectFiles[id];
if (files && files[name]) navigator.clipboard.writeText(files[name]);
};
window.copyAllProjectFiles = function(id) {
const files = window._projectFiles[id];
if (!files) return;
const text = Object.entries(files).map(([n,c]) => '# --- ' + n + ' ---\n' + c).join('\n\n');
navigator.clipboard.writeText(text);
};
window.switchProjTab = function(id,i) {
document.querySelectorAll(`.project-tab[data-card="${id}"]`).forEach((t,j) => t.classList.toggle('active', j===i));
document.querySelectorAll(`.proj-panel[data-card="${id}"]`).forEach((p,j) => p.style.display = j===i ? '' : 'none');
};
// === Monaco Editor (lazy load) ===
window.MonacoEnvironment = { getWorkerUrl: () => `data:text/javascript,self.MonacoEnvironment={baseUrl:'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/'};importScripts('https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js')` };
let _monacoInitPromise = null;
function initMonaco() {
if (_monacoInitPromise) return _monacoInitPromise;
_monacoInitPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
s.onerror = () => reject(new Error('Monaco loader lataus epäonnistui'));
s.onload = () => {
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }});
require(['vs/editor/editor.main'], () => {
window.monaco = monaco;
window._monaco = monaco.editor.create(document.getElementById('monaco-container'), {
value: '// Valitse tiedosto tai generoi projekti\n',
language: 'plaintext', theme: 'vs-dark', fontSize: 14,
minimap: { enabled: false }, automaticLayout: true, padding: { top: 10 }
});
console.log('[Monaco] Valmis');
resolve();
}, (err) => reject(new Error('Monaco AMD load: ' + err)));
};
document.head.appendChild(s);
});
return _monacoInitPromise;
}
window._editorModels = {};
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) {
switchTab('editor');
try { await initMonaco(); } catch(e) { console.error('Monaco-virhe:', e); return; }
const m = window.monaco;
if (!m) { console.error('Monaco ei latautunut'); return; }
for (const [name,code] of Object.entries(files)) {
const ext = name.split('.').pop().toLowerCase();
if (window._editorModels[name]) window._editorModels[name].setValue(code);
else window._editorModels[name] = m.editor.createModel(code, langMap[ext]||'plaintext');
}
document.getElementById('editor-file-list').innerHTML = Object.keys(files).map(n => `<div class="dd-item" onclick="openFile('${n}')">${n}</div>`).join('');
document.getElementById('editor-tabs').innerHTML = Object.keys(files).map(n => `<div class="project-tab" onclick="openFile('${n}')">${n}</div>`).join('');
openFile(Object.keys(files)[0]);
};
window.openFile = function(name) {
if (!window._editorModels[name] || !window._monaco) return;
window._monaco.setModel(window._editorModels[name]);
document.querySelectorAll('#editor-file-list .dd-item').forEach(el => el.style.background = el.textContent===name ? 'var(--border)' : '');
document.querySelectorAll('#editor-tabs .project-tab').forEach(el => el.classList.toggle('active', el.textContent===name));
};
// === Guide loader ===
(async () => {
const el = document.getElementById('guide-content');
try {
const r = await fetch('/GUIDE.md');
if (r.ok) el.innerHTML = renderMd(await r.text());
el.querySelectorAll('pre code').forEach(b => { if (typeof hljs !== 'undefined') hljs.highlightElement(b); });
// Mermaid-kaaviot
if (window.mermaid) {
el.querySelectorAll('.mermaid-block').forEach(async block => {
try {
const { svg } = await window.mermaid.render('r-' + block.id, block.textContent.trim());
block.innerHTML = svg;
} catch(e) { /* fallback: näytetään koodi */ }
});
}
} catch(e) { el.textContent = 'Virhe: ' + e.message; }
})();
function renderMd(md) {
const lines = md.split('\n');
let html = '', inCode = false, lang = '', buf = '';
let inTable = false, tableRows = [];
function inline(text) {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:#e6edf3">$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code style="background:var(--panel);padding:1px 5px;border-radius:3px;font-size:13px;color:#e6edf3">$1</code>');
}
function flushTable() {
if (!inTable || tableRows.length < 2) { inTable = false; tableRows = []; return; }
const hdr = tableRows[0].split('|').filter(c => c.trim());
const body = tableRows.slice(2);
html += '<div style="overflow-x:auto;margin:12px 0"><table style="width:100%;border-collapse:collapse;font-size:14px">';
html += '<thead><tr>' + hdr.map(c => `<th style="text-align:left;padding:8px 12px;border-bottom:2px solid var(--border);color:var(--accent);font-weight:600">${inline(c.trim())}</th>`).join('') + '</tr></thead><tbody>';
for (const row of body) {
const cells = row.split('|').filter(c => c.trim());
if (cells.length) html += '<tr>' + cells.map(c => `<td style="padding:6px 12px;border-bottom:1px solid #21262d">${inline(c.trim())}</td>`).join('') + '</tr>';
}
html += '</tbody></table></div>';
inTable = false; tableRows = [];
}
for (const line of lines) {
// Koodiblokit + Mermaid
if (line.startsWith('```')) {
if (inCode) {
if (lang === 'mermaid') {
const mid = 'mmd-' + Math.random().toString(36).slice(2,8);
html += `<div class="mermaid-block" id="${mid}" style="margin:16px 0;text-align:center">${buf.replace(/</g,'&lt;')}</div>`;
} else {
html += `<pre class="code-block"><code class="language-${lang}">${buf.replace(/</g,'&lt;')}</code></pre>`;
}
inCode = false; buf = '';
} else {
flushTable();
inCode = true; lang = line.slice(3).trim() || 'plaintext';
}
continue;
}
if (inCode) { buf += (buf ? '\n' : '') + line; continue; }
// Taulukot
if (line.includes('|') && line.trim().startsWith('|')) {
if (!inTable) inTable = true;
tableRows.push(line);
continue;
} else { flushTable(); }
// Tyhjä rivi
if (!line.trim()) { html += '<div style="height:8px"></div>'; continue; }
// Otsikot
if (line.startsWith('# ')) { html += `<h1 style="color:#e6edf3;font-size:26px;margin:28px 0 10px;border-bottom:1px solid var(--border);padding-bottom:8px">${inline(line.slice(2))}</h1>`; continue; }
if (line.startsWith('## ')) { html += `<h2 style="color:#e6edf3;font-size:21px;margin:24px 0 8px;border-bottom:1px solid #21262d;padding-bottom:6px">${inline(line.slice(3))}</h2>`; continue; }
if (line.startsWith('### ')) { html += `<h3 style="color:#e6edf3;font-size:17px;margin:18px 0 6px">${inline(line.slice(4))}</h3>`; continue; }
// Viiva
if (line.match(/^-{3,}$/)) { html += '<hr style="border:none;border-top:1px solid var(--border);margin:20px 0">'; continue; }
// Listat
if (line.match(/^[\-\*] /)) { html += `<div style="padding:2px 0 2px 20px">${inline(line.replace(/^[\-\*] /, '• '))}</div>`; continue; }
if (line.match(/^\d+\. /)) { html += `<div style="padding:2px 0 2px 20px">${inline(line)}</div>`; continue; }
// Normaali teksti
html += `<p style="margin:4px 0">${inline(line)}</p>`;
}
flushTable();
return html;
}
// === Settings panel ===
function initSettings() {
const els = {
systemPrompt: document.getElementById('set-system-prompt'),
temperature: document.getElementById('set-temperature'),
tempVal: document.getElementById('set-temp-val'),
topK: document.getElementById('set-topk'),
topkVal: document.getElementById('set-topk-val'),
repeat: document.getElementById('set-repeat'),
repVal: document.getElementById('set-rep-val'),
maxTokens: document.getElementById('set-maxtokens'),
maxtokVal: document.getElementById('set-maxtok-val'),
stopSeq: document.getElementById('set-stop-sequences'),
model: document.getElementById('set-model'),
};
if (!els.systemPrompt) return;
// Lataa arvot
els.systemPrompt.value = settings.systemPrompt;
els.temperature.value = settings.temperature;
els.tempVal.textContent = settings.temperature;
els.topK.value = settings.topK;
els.topkVal.textContent = settings.topK;
els.repeat.value = settings.repeatPenalty;
els.repVal.textContent = settings.repeatPenalty;
els.maxTokens.value = settings.maxTokens;
els.maxtokVal.textContent = settings.maxTokens;
els.stopSeq.value = settings.stopSequences.replace(/\\n/g, '\n');
els.model.value = settings.model;
// Tallenna muutokset
els.systemPrompt.oninput = () => { settings.systemPrompt = els.systemPrompt.value; saveSettings(); };
els.temperature.oninput = () => { settings.temperature = +els.temperature.value; els.tempVal.textContent = settings.temperature; saveSettings(); };
els.topK.oninput = () => { settings.topK = +els.topK.value; els.topkVal.textContent = settings.topK; saveSettings(); };
els.repeat.oninput = () => { settings.repeatPenalty = +els.repeat.value; els.repVal.textContent = settings.repeatPenalty; saveSettings(); };
els.maxTokens.oninput = () => { settings.maxTokens = +els.maxTokens.value; els.maxtokVal.textContent = settings.maxTokens; saveSettings(); };
els.stopSeq.oninput = () => { settings.stopSequences = els.stopSeq.value.replace(/\n/g, '\\n'); saveSettings(); };
els.model.onchange = () => { settings.model = els.model.value; saveSettings(); };
}
// Alustetaan kun settings-tab avataan
const origSwitchTab = window.switchTab;
window.switchTab = function(tab) {
origSwitchTab(tab);
if (tab === 'settings') initSettings();
};
window.resetSettings = function() {
if (!confirm('Palautetaanko kaikki asetukset oletuksiin?')) return;
settings = { ...defaultSettings };
saveSettings();
initSettings();
};
// === Raportti-modal ===
window.showReportModal = function(markdown) {
let modal = document.getElementById('report-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'report-modal';
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:1000;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)';
modal.innerHTML = `<div style="background:var(--panel);border:1px solid var(--border);border-radius:8px;max-width:800px;width:90%;max-height:85vh;overflow-y:auto;padding:24px;position:relative">
<button onclick="document.getElementById('report-modal').style.display='none'" style="position:absolute;top:12px;right:12px;background:none;border:none;color:#8b949e;font-size:20px;cursor:pointer">✕</button>
<div id="report-modal-content" style="line-height:1.7;font-size:15px"></div>
</div>`;
document.body.appendChild(modal);
}
document.getElementById('report-modal-content').innerHTML = renderMd(markdown);
document.getElementById('report-modal-content').querySelectorAll('pre code').forEach(b => { if (typeof hljs !== 'undefined') hljs.highlightElement(b); });
modal.style.display = 'flex';
modal.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; });
};
</script>
</body>
</html>