Files
agentic-studio/network-poc/frontend/dist/index.html
Jaakko Vanhala a8c4af0975 Frontend uudelleenrakennettu: Astro-komponentit, Wasm pääsäikeessä, ei Workeria
Vanha frontend siirretty temp/. Uusi rakenne:
- StatusBar.astro, Terminal.astro, Editor.astro, Guide.astro
- global.css erillinen
- Wasm pääsäikeessä (ei Worker — yksinkertainen, debugattava)
- Tab-completion, dropdown, projektikortti, Monaco, GUIDE.md
- Ei tokenisointia eikä koodilaboratoriota

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

519 lines
37 KiB
HTML

<!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 type="module" src="/_astro/index.astro_astro_type_script_index_0_lang.B57BFWqC.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css"><style>:root{--bg: #0d1117;--panel: #161b22;--text: #c9d1d9;--accent: #58a6ff;--green: #3fb950;--yellow: #d29922;--red: #f85149;--purple: #a371f7;--border: #30363d}*{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}.container{max-width:1200px;margin:0 auto;padding:20px}.tabs{display:flex;gap:4px;margin-bottom:16px}.tab{padding:8px 16px;border-radius:6px 6px 0 0;cursor:pointer;border:1px solid var(--border);border-bottom:none;background:var(--bg);color:#8b949e;font-size:14px}.tab.active{background:var(--panel);color:var(--accent);border-color:var(--border)}.panel{display:none}.panel.active{display:block}.status-bar{display:flex;align-items:center;gap:12px;padding:8px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px 6px 0 0;font-family:Courier New,monospace;font-size:13px}.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block}.status-group{display:flex;align-items:center;gap:6px}.status-separator{color:var(--border)}.terminal{background:#010409;border:1px solid var(--border);border-top:none;font-family:Courier New,monospace;font-size:14px;min-height:300px;max-height:60vh;overflow-y:auto;padding:8px 12px}.terminal-line{padding:1px 0;white-space:pre-wrap;word-break:break-word}.terminal-prompt{color:var(--yellow);margin-right:8px}.terminal-input-row{display:flex;align-items:center;position:relative;background:#010409;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:8px 12px;font-family:Courier New,monospace;font-size:14px}.terminal-input{flex:1;background:transparent;border:none;outline:none;color:var(--green);font-family:inherit;font-size:inherit}.terminal-dropdown{display:none;position:absolute;bottom:100%;left:30px;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:200px;overflow-y:auto;font-size:13px;min-width:200px;z-index:100;box-shadow:0 4px 12px #0006}.dd-item{padding:6px 12px;cursor:pointer;color:var(--text);white-space:nowrap;border-bottom:1px solid #21262d}.dd-item:hover,.dd-item.active{background:var(--border);color:var(--accent)}.pipeline-bar{display:none;padding:8px 14px;background:var(--bg);border:1px solid var(--border);border-top:none;font-family:Courier New,monospace;font-size:12px;overflow-x:auto;white-space:nowrap}.project-card{margin:8px 0;border:1px solid var(--border);border-radius:6px;background:var(--panel);overflow:hidden}.project-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border)}.project-tabs{display:flex;gap:2px;padding:6px 8px 0;background:var(--bg)}.project-tab{padding:4px 10px;cursor:pointer;border-radius:4px 4px 0 0;font-size:12px;color:#8b949e}.project-tab.active{background:var(--panel);color:var(--accent);border:1px solid var(--border);border-bottom:none}.btn{padding:2px 10px;border-radius:4px;border:1px solid var(--border);background:var(--panel);font-size:12px;font-family:inherit;cursor:pointer}.btn-accent{color:var(--accent)}.btn-green{color:var(--green);border-color:var(--green)}.btn-red{color:var(--red);border-color:var(--red)}.btn-muted{color:#8b949e;background:none}.code-block{font-family:Courier New,monospace;background:#010409;border:1px solid var(--border);border-radius:6px;padding:14px;font-size:13px;line-height:1.6;white-space:pre-wrap;overflow-x:auto;max-height:400px;overflow-y:auto}.code-block .hljs{background:transparent;padding:0}@keyframes blink{0%,to{opacity:1}50%{opacity:0}}@keyframes spin{to{transform:rotate(360deg)}}
</style></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"> <!-- Hub-yhteys + laskentasolmun tila --><div class="status-bar"> <span class="status-group" title="Hub-yhteyden tila"> <span id="hub-dot" class="status-dot" style="background:#d29922"></span> <span style="color:#8b949e">Hub:</span> <span id="hub-label" style="color:#d29922">Yhdistetään...</span> </span> <span class="status-separator"></span> <span class="status-group"> <span id="compute-dot" class="status-dot" style="background:#30363d"></span> <span style="color:#8b949e">Laskenta:</span> <span id="compute-label" style="color:#8b949e"></span> <button id="compute-btn" class="btn btn-accent" title="Käynnistä kielimalli">Alusta</button> </span> </div> <!-- Pipeline-palkki + Terminaali + Input --><div id="pipeline-bar" class="pipeline-bar"></div> <div id="terminal" class="terminal"></div> <div class="terminal-input-row"> <span class="terminal-prompt">$</span> <input id="term-input" class="terminal-input" type="text" placeholder="kpn run coder &quot;hello world in python&quot;" spellcheck="false" autocomplete="off"> <div id="term-dropdown" class="terminal-dropdown"></div> </div> </div> <!-- Monaco Editor paneeli --><div id="panel-editor" class="panel"> <div style="display:flex;height:calc(100vh - 200px);gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden"> <div id="editor-filetree" style="width:200px;min-width:150px;background:var(--bg);border-right:1px solid var(--border);overflow-y:auto;font-family:'Courier New',monospace;font-size:13px"> <div style="padding:10px 12px;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">Tiedostot</div> <div id="editor-file-list" style="padding:4px 0"> <div style="padding:8px 16px;color:#8b949e;font-size:12px">Generoi projekti:<br><code style="color:var(--accent)">kpn project "..."</code></div> </div> </div> <div style="flex:1;display:flex;flex-direction:column"> <div id="editor-tabs" style="display:flex;background:var(--bg);border-bottom:1px solid var(--border);min-height:35px;align-items:flex-end;padding:0 8px;gap:2px;overflow-x:auto"></div> <div id="monaco-container" style="flex:1"></div> </div> </div> </div> <!-- Opas-paneeli: ladataan GUIDE.md fetchillä --><div id="panel-guide" class="panel"> <div id="guide-content" style="max-width:800px;margin:0 auto;padding:20px;line-height:1.7;font-size:15px"> <p style="color:#8b949e">Ladataan opasta...</p> </div> </div> </div> <script>
// === 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);
}
// === 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);
// === 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 {
// Varmistetaan solmu
if (!wasmNodeStarted) {
statusDiv.innerHTML = ' <span style="color:#d29922">→ Käynnistetään laskentasolmua...</span>';
await ensureNode();
// Odotetaan kunnes malli on latautunut tai 60s
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;
}
}
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.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: 'smollm-135m', qa: 'smollm-135m' };
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('smollm-135m', `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('smollm-135m', `Review briefly:\n${code}`);
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis ━━━</span>`);
}
// === Project card ===
function renderProjectCard(files, name) {
const entries = Object.entries(files);
if (!entries.length) return;
const id = 'proj-' + Date.now();
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="navigator.clipboard.writeText(JSON.parse(document.getElementById('${id}').dataset.files)['${n}'])">Kopioi</button></div><pre class="code-block">${highlightCode(c)}</pre></div>`).join('');
const html = `<div id="${id}" class="project-card" data-files='${esc(JSON.stringify(files))}'><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="navigator.clipboard.writeText(Object.entries(JSON.parse(document.getElementById('${id}').dataset.files)).map(([n,c])=>'# --- '+n+' ---\\n'+c).join('\\n\\n'))">Kopioi kaikki</button><button class="btn btn-green" onclick="openInEditor(JSON.parse(document.getElementById('${id}').dataset.files))">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.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) ===
let monacoLoaded = false;
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'],()=>r()); }; document.head.appendChild(s); });
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} });
}
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 = function(files) {
switchTab('editor');
initMonaco().then(() => {
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] = monaco.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>