Files
agentic-studio/network-poc/frontend/src/pages/index.astro
Jaakko Vanhala 91dc7579bc Per-agentti sampling-parametrit: temperature, top-k, max tokens, repetition penalty
Jokainen agentti saa omat parametrit jotka näkyvät avatarin konfigurointipaneelissa:
- Temperature: Manageri 0.5 (tarkka), Koodari 0.7, Testaaja 0.3 (deterministinen)
- Max tokens: Manageri 512, Koodari 1024, Testaaja 512
- Top-K ja Repetition penalty per agentti
- Sliderit reaaliaikaisilla arvoilla

Parametrit tallentuvat localStorageen agentin mukana.
Perustelut: manageri ja testaaja hyötyvät matalasta temperaturesta
(determinismi tärkeää), koodari tarvitsee enemmän tokeneita ja
hieman korkeamman temperaturen luovempiin ratkaisuihin.

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

899 lines
48 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>
<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.
Break the project into individual source files. List dependencies first (models before app).
Use pyproject.toml for Python dependencies (not requirements.txt).
Max 4-5 files. Only filenames, no directories. Format: filename.py: description` },
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/Rust developer. Write complete, working code with ALL necessary imports.
Use separate names for Pydantic schemas (e.g. UserCreate, UserResponse) and SQLAlchemy models (e.g. User).
Always import from other project files when they exist (e.g. from models import User, SessionLocal).
Use modern Python: type hints, async/await for FastAPI, f-strings.
No explanations, no comments unless complex logic. Only code.` },
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 and data engineer.
Design normalized schemas with proper relationships, indexes, and constraints.
Use SQLAlchemy ORM. Define engine, SessionLocal, and Base in a shared database.py.
Include migration-friendly patterns. Write complete code with all imports.` },
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 specializing in automated testing.
Write pytest tests covering happy paths, edge cases, and error handling.
Test API endpoints with TestClient. Mock external dependencies.
Verify status codes, response schemas, and database state after operations.` },
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 code reviewer and DevOps engineer.
Review code for: missing imports, name conflicts, unhandled errors, security issues.
Check that all files are consistent (imports match exports).
If code is correct, say "LGTM". Otherwise list specific issues with file:line references.
Be brief and actionable.` },
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.
Monitor the team output for: architectural issues, security vulnerabilities, missing error handling,
inconsistent patterns, and scope creep. Flag critical risks immediately.
Provide a brief risk assessment with severity (low/medium/high/critical).` },
};
let agents = JSON.parse(localStorage.getItem('kpn-agents') || 'null') || JSON.parse(JSON.stringify(defaultAgents));
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;
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; }
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;
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step}/${template.order.length}] ${esc(cdr.name)}</span> — ${esc(fileName)}`);
// Opettava selitys: miksi tämä tiedosto, mitä se sisältää
explainStep(fileName, fileDef.instructions);
// Rakennetaan prompti: esimerkki + konteksti + ohje
let prompt = '';
// Agentin system prompt
if (cdr.prompt) prompt += cdr.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(cdr.model, prompt);
if (!code) {
termLog(` ✗ Keskeytyi (${fileName})`, '#f85149');
return;
}
files[fileName] = code;
}
// Review
const allCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
termLog(`\n<span style="color:var(--accent);font-weight:bold">[${template.order.length + 1}] ${esc(tst.name)}</span> — review`);
explainStep('Koodikatselmointi', 'Testaaja tarkistaa importit, nimeämiset, puuttuvat virheenkäsittelyt ja tiedostojen yhteensopivuuden.');
const tstPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') +
`Review this project. Check:\n1. All imports are correct (files import from each other)\n2. Pydantic schema names don't conflict with SQLAlchemy models\n3. All CRUD endpoints exist\n4. Error handling is present\nIf everything is correct, say "LGTM". Otherwise list specific issues.\n\n${allCode}`;
const review = await kpnRun(tst.model, tstPrompt);
if (review && !review.toLowerCase().includes('lgtm')) {
termLog(`\n<span style="color:#d29922;font-weight:bold">[${template.order.length + 2}] ${esc(cdr.name)}</span> — korjaukset`);
explainStep('Korjausluuppi', 'Testaaja löysi ongelmia. Koodari 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.`);
}
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. Aja: uv run uvicorn main:app --reload`);
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); });
} catch(e) { el.textContent = 'Virhe: ' + e.message; }
})();
function renderMd(md) {
let html = '', inCode = false, lang = '', buf = '';
for (const line of md.split('\n')) {
if (line.startsWith('```')) { if (inCode) { html += `<pre class="code-block"><code class="language-${lang}">${buf.replace(/</g,'&lt;')}</code></pre>`; inCode=false; buf=''; } else { inCode=true; lang=line.slice(3).trim()||'plaintext'; } continue; }
if (inCode) { buf += (buf?'\n':'') + line; continue; }
if (!line.trim()) { html += '<br>'; continue; }
if (line.startsWith('# ')) html += `<h1 style="color:#e6edf3;font-size:24px;margin:24px 0 8px;border-bottom:1px solid var(--border);padding-bottom:6px">${line.slice(2)}</h1>`;
else if (line.startsWith('## ')) html += `<h2 style="color:#e6edf3;font-size:20px;margin:20px 0 8px">${line.slice(3)}</h2>`;
else if (line.startsWith('### ')) html += `<h3 style="color:#e6edf3;font-size:16px;margin:16px 0 6px">${line.slice(4)}</h3>`;
else if (line.startsWith('---')) html += '<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">';
else if (line.match(/^[\-\*] /)) html += `<div style="padding:2px 0 2px 20px">${line.replace(/^[\-\*] /,'• ').replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/`(.+?)`/g,'<code style="background:var(--panel);padding:1px 4px;border-radius:3px;font-size:13px">$1</code>')}</div>`;
else html += `<p style="margin:4px 0">${line.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/`(.+?)`/g,'<code style="background:var(--panel);padding:1px 4px;border-radius:3px;font-size:13px">$1</code>')}</p>`;
}
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();
};
</script>
</body>
</html>