let ei hoistu — monacoLoaded pitää olla määritelty ennen initMonaco-kutsua. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
622 lines
33 KiB
Plaintext
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
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 <malli> "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 <malli> "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,'<')}</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>
|