Files
agentic-studio/network-poc/frontend/src/pages/index.astro
Jaakko Vanhala 5528be1812 Korjattu monacoLoaded: siirretty scriptin alkuun ennen switchTab-kutsua
let ei hoistu — monacoLoaded pitää olla määritelty ennen initMonaco-kutsua.

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

622 lines
33 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";
---
<!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>
<!-- Agents-paneeli -->
<div id="panel-agents" class="panel active">
<AgentBar />
<StatusBar />
<Terminal />
</div>
<Editor />
<Guide />
</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 ===
let monacoLoaded = false;
// === 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 ===
window.selectAgent = function(agent) {
document.querySelectorAll('.agent-avatar').forEach(el => el.classList.remove('active'));
document.querySelector(`.agent-avatar[data-agent="${agent}"]`)?.classList.add('active');
};
// === WebSocket ===
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
const uiSocket = new WebSocket(wsUrl);
window._uiSocket = uiSocket;
uiSocket.onopen = () => {
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',
}));
};
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();
});
// Autostart
if (localStorage.getItem('kpn-coder-loaded') === 'true') {
setTimeout(() => ensureNode(), 300);
}
// === 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 = { coder: 'qwen-coder', 'coder-3b': 'qwen-coder-3b', manager: 'qwen-coder', tester: 'qwen-coder', qa: 'qwen-coder' };
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(); });
// === Project pipeline ===
async function kpnProject(task) {
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ Projekti käynnistyy ━━━</span>`);
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — suunnittelu`);
const plan = await kpnRun('qwen-coder', `List the source files needed for this project. One file per line, format:\nfilename.py: what this file contains\n\nRules:\n- Max 4 files\n- Only .py, .toml, .json, .html files\n- No directories, just filenames\n- Dependencies first (models.py before main.py)\n- Use pyproject.toml for deps\n\nProject: ${task}`);
if (!plan) { termLog(' ✗ Keskeytyi', '#f85149'); return; }
const fileList = plan.split('\n').map(l => l.trim().replace(/^[\d\.\-\*\s]+/,'').replace(/\*+/g,'').replace(/`/g,'')).map(l => {
if (l.includes(':')) { const [n,...d] = l.split(':'); return { name: n.trim(), desc: d.join(':').trim() }; }
return { name: l.trim(), desc: '' };
}).filter(f => f.name.length > 0 && f.name.length < 40 && !f.name.includes('/') && !f.name.includes(' ') && /\.\w{1,5}$/.test(f.name));
if (!fileList.length) {
termLog(' Ei tiedostojakoa — generoidaan yhtenä', '#8b949e');
await kpnRun('qwen-coder', `Project: ${task}\n\nWrite all the code.`);
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis ━━━</span>`);
return;
}
termLog(` <span style="color:#8b949e">${fileList.length} tiedostoa: ${fileList.map(f=>f.name).join(', ')}</span>`);
const files = {};
for (let i = 0; i < fileList.length; i++) {
const f = fileList[i];
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${i+2}] Koodari</span> — ${esc(f.name)}`);
let ctx = '';
const prev = Object.entries(files);
if (prev.length) ctx = 'Already written:\n' + prev.map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n') + '\n\n';
let extra = '';
if (f.name === 'pyproject.toml') extra = '\nUse format: [project]\\nname="proj"\\nversion="0.1.0"\\nrequires-python=">=3.11"\\ndependencies=["fastapi","uvicorn"]';
const code = await kpnRun('qwen-coder', `${ctx}Project: ${task}\nWrite ONLY "${f.name}"${f.desc ? ': '+f.desc : ''}.${extra}\nUse exact libraries from project description.`);
if (!code) { termLog(` ✗ Keskeytyi (${f.name})`, '#f85149'); return; }
files[f.name] = 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">[${fileList.length+2}] Testaaja</span> — review`);
const review = await kpnRun('qwen-coder', `Review briefly. Say LGTM if ok.\n${allCode}`);
if (review && !review.toLowerCase().includes('lgtm')) {
termLog(`\n<span style="color:#d29922;font-weight:bold">[${fileList.length+3}] Korjaukset</span>`);
await kpnRun('qwen-coder', `Fix issues:\n${review}\n\nCode:\n${allCode}`);
}
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis (${Object.keys(files).length} tiedostoa) ━━━</span>`);
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')` };
async function initMonaco() {
if (monacoLoaded) return;
monacoLoaded = true;
await new Promise(r => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
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; // Varmistetaan globaali
r();
});
};
document.head.appendChild(s);
});
window._monaco = window.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 }
});
}
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');
await initMonaco();
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;
}
</script>
</body>
</html>