Files
agentic-studio/network-poc/static/index.html
jaakko 48c832c61b Module import absoluuttiseksi: ./pkg/node.js → /pkg/node.js
Suhteellinen polku rikkoi sivun kun navigoitiin suoraan
/avatars/ tai muuhun alihakemistoon (selain yritti ladata
/avatars/pkg/node.js jota ei ollut).

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

5231 lines
280 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kipinä Agentic Playground</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
:root {
--bg-color: #0d1117;
--panel-bg: #161b22;
--text-color: #c9d1d9;
--accent-color: #58a6ff;
--success-color: #3fb950;
--border-color: #30363d;
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 20px;
flex-direction: column;
box-sizing: border-box;
}
.container {
background-color: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 30px;
width: 100%;
max-width: 1400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
text-align: center;
margin-bottom: 20px;
}
.device-info {
background-color: #0d1117;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
color: #8b949e;
text-align: left;
display: none;
}
.device-info span { color: var(--text-color); }
.dashboard-panel {
background-color: #0d1117;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 15px;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.stat-box {
text-align: center;
flex-grow: 1;
}
.stat-box h3 {
margin: 0;
color: var(--accent-color);
font-size: 28px;
}
.stat-box p {
margin: 5px 0 0 0;
font-size: 14px;
color: #8b949e;
}
.slider-container {
margin: 20px 0;
text-align: left;
}
input[type=range] {
width: 100%;
margin-top: 10px;
accent-color: var(--accent-color);
}
h1 { margin-bottom: 5px; }
h1 span { color: var(--accent-color); }
.sub { color: #8b949e; margin-bottom: 25px; }
.main-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 2px solid var(--border-color);
padding-bottom: 0;
}
.main-tab {
padding: 10px 20px;
font-size: 15px;
font-weight: 500;
color: #8b949e;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.main-tab:hover { color: var(--text-color); }
.main-tab.active { color: var(--accent-color); border-bottom-color: var(--accent-color); }
.main-panel { display: none; }
.main-panel.active { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } }
.code-output {
font-family: 'Courier New', Courier, monospace;
background: #010409;
border: 1px solid var(--border-color);
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-output .hljs { background: transparent; padding: 0; }
#guide-content { scrollbar-color: #30363d transparent; }
#guide-content h1 { color: #e6edf3; }
#guide-content h2 { color: #e6edf3; }
#guide-content a { color: #58a6ff; }
#guide-content table { border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
#guide-content pre { scrollbar-color: #30363d transparent; }
.builder-tip { position: relative; }
.builder-tip[data-tip]:hover::after {
content: attr(data-tip);
position: absolute; bottom: calc(100% + 8px); left: 0;
background: #1c2028; border: 1px solid #30363d; border-radius: 8px;
padding: 10px 14px; font-size: 12px; color: #c9d1d9;
white-space: pre-wrap; max-width: 340px; min-width: 220px;
z-index: 100; box-shadow: 0 6px 16px rgba(0,0,0,0.5);
pointer-events: none; line-height: 1.6; font-weight: 400;
}
.code-task-card {
background: #0d1117;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 14px;
margin-bottom: 12px;
}
.code-task-card .prompt { color: #d29922; font-size: 14px; margin-bottom: 10px; }
.code-task-card .meta { color: #8b949e; font-size: 12px; margin-top: 10px; }
.code-step {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #8b949e;
padding: 6px 0;
}
.code-step.active { color: var(--accent-color); }
.code-step.done { color: var(--success-color); }
.code-step.error { color: #f85149; }
.step-icon { font-size: 16px; width: 20px; text-align: center; }
.status-box {
font-family: 'Courier New', Courier, monospace;
background-color: #010409;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 15px;
height: 120px;
overflow-y: auto;
text-align: left;
}
.status-box p {
margin: 0 0 5px 0;
color: var(--success-color);
font-size: 14px;
}
.btn {
background-color: #238636;
color: #ffffff;
border: 1px solid rgba(240, 246, 252, 0.1);
border-radius: 6px;
padding: 10px 20px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover { background-color: #2ea043; }
.hidden { display: none; }
.compat-banner {
border-radius: 6px;
padding: 14px 18px;
margin-bottom: 20px;
font-size: 14px;
line-height: 1.6;
display: none;
}
.compat-banner.gpu {
background: #23392020;
border: 1px solid #3fb95040;
color: var(--success-color);
}
.compat-banner.cpu {
background: #d2992215;
border: 1px solid #d2992240;
color: #d29922;
}
.compat-banner code {
background: #0d1117;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
color: var(--text-color);
}
.compat-banner summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 6px;
}
.compat-banner details[open] summary {
margin-bottom: 10px;
}
.chat-box {
background-color: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 15px;
height: 500px;
overflow-y: auto;
text-align: left;
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-msg {
background-color: #0d1117;
padding: 12px;
border-radius: 6px;
border-left: 3px solid var(--accent-color);
font-size: 15px;
}
.chat-prompt {
color: #8b949e;
font-size: 13px;
margin-bottom: 5px;
display: block;
}
.token-detail {
background: #010409;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px 12px;
margin-top: 8px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.8;
display: none;
}
.token-detail.visible { display: block; }
.token-detail .tok {
background: #1c2333;
border: 1px solid #30363d;
border-radius: 3px;
padding: 2px 5px;
margin: 2px;
display: inline-block;
color: var(--text-color);
}
.token-detail .tok-en { border-color: #58a6ff44; }
.token-detail .tok-fi { border-color: #d2992244; }
.toggle-tokens {
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
color: #8b949e;
font-size: 12px;
padding: 3px 8px;
cursor: pointer;
}
.toggle-tokens:hover { color: var(--text-color); border-color: #8b949e; }
.task-option {
background: var(--panel-bg);
border: 2px solid var(--border-color);
border-radius: 8px;
padding: 14px;
cursor: pointer;
transition: border-color 0.2s;
position: relative;
}
.task-option:hover { border-color: #8b949e; }
.task-option.selected { border-color: var(--accent-color); background: #58a6ff10; }
.task-title { font-weight: 600; font-size: 15px; color: var(--text-color); margin-bottom: 4px; }
.task-desc { font-size: 12px; color: #8b949e; line-height: 1.4; margin-bottom: 8px; }
.task-size { font-size: 11px; color: #6e7681; }
.task-badge {
position: absolute;
top: 10px;
right: 10px;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
.task-ready { background: #23392050; color: var(--success-color); border: 1px solid #23392080; }
.task-soon { background: #d2992215; color: #d29922; border: 1px solid #d2992240; }
.task-info {
display: none;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
font-size: 12px;
line-height: 1.6;
color: #8b949e;
}
.task-info strong { color: var(--text-color); }
.task-info em { color: var(--accent-color); font-style: normal; }
.task-option.selected .task-info { display: block; }
.download-bar {
background: #0d1117;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 16px;
display: none;
}
.download-bar .bar-track {
background: #21262d;
border-radius: 4px;
height: 8px;
margin-top: 8px;
overflow: hidden;
}
.download-bar .bar-fill {
background: var(--accent-color);
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.metric-card {
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
text-align: center;
}
.metric-val {
font-size: 20px;
font-weight: 700;
color: var(--accent-color);
}
.metric-label {
font-size: 11px;
color: #8b949e;
margin-top: 2px;
}
.terminal-panel {
background:#010409;
border:1px solid var(--border-color);
border-radius:6px;
padding:15px;
font-family: 'Courier New', Courier, monospace;
font-size:14px;
color:var(--success-color);
height: clamp(200px, 35vh, 500px);
overflow-y:auto;
text-align:left;
white-space: pre-wrap;
}
.terminal-line { margin: 4px 0; }
.terminal-prompt { color: #d29922; }
.org-chart {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 10px 12px;
overflow-x: auto;
}
.org-level {
display: flex;
gap: 6px;
position: relative;
z-index: 2;
flex-shrink: 0;
}
.org-connector {
width: 20px;
height: 2px;
background: linear-gradient(to right, rgba(88, 166, 255, 0.8), rgba(88, 166, 255, 0.2));
align-self: center;
flex-shrink: 0;
}
.org-branch {
display: none;
}
.org-chart.vertical {
flex-direction: column;
align-items: center;
gap: 0;
}
.org-chart.vertical .org-connector {
width: 2px;
height: 24px;
background: linear-gradient(to bottom, rgba(88, 166, 255, 0.8), rgba(88, 166, 255, 0.2));
}
.avatar-card {
background: linear-gradient(145deg, rgba(33, 38, 45, 0.4) 0%, rgba(13, 17, 23, 0.8) 100%);
backdrop-filter: blur(12px);
border: 1px solid rgba(240, 246, 252, 0.1);
border-radius: 12px;
padding: 6px 6px 4px;
text-align: center;
width: 72px;
opacity: 0.8;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.avatar-card:hover {
opacity: 0.85;
transform: translateY(-2px) scale(1.02);
border-color: rgba(240, 246, 252, 0.3);
box-shadow: 0 8px 14px rgba(0,0,0,0.4);
}
@keyframes idle-breathe {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-2px) scale(1.01); }
}
@keyframes talking-head {
0% { transform: scale(1.05) scaleY(1) translateY(0); }
25% { transform: scale(1.05) scaleY(0.96) scaleX(1.02) translateY(2px); }
50% { transform: scale(1.05) scaleY(1.02) scaleX(0.98) translateY(-2px); }
75% { transform: scale(1.05) scaleY(0.97) scaleX(1.01) translateY(1px); }
100% { transform: scale(1.05) scaleY(1) translateY(0); }
}
.avatar-card img {
width: 50px;
height: 50px;
border-radius: 12px;
margin-bottom: 4px;
border: 2px solid rgba(240, 246, 252, 0.1);
transition: all 0.4s ease;
object-fit: cover;
background: #010409;
animation: idle-breathe 4s infinite ease-in-out;
transform-origin: bottom center;
}
.avatar-card.active, .avatar-card.selected {
opacity: 1;
transform: translateY(-8px) scale(1.05);
border-color: var(--accent-color);
background: linear-gradient(145deg, rgba(88, 166, 255, 0.15) 0%, rgba(13, 17, 23, 0.9) 100%);
box-shadow: 0 16px 24px rgba(0,0,0,0.5), 0 0 20px rgba(88, 166, 255, 0.3);
z-index: 2;
}
.avatar-card.selected img {
border-color: var(--accent-color);
box-shadow: 0 0 25px rgba(88, 166, 255, 0.5);
transform: scale(1.05);
animation: none;
}
.avatar-card.active img {
border-color: var(--accent-color);
box-shadow: 0 0 25px rgba(88, 166, 255, 0.8);
animation: talking-head 0.4s infinite ease-in-out;
transform-origin: bottom center;
}
@keyframes talking-head-gallery {
0% { transform: scaleY(1) translateY(0); }
25% { transform: scaleY(0.94) scaleX(1.04) translateY(3px); }
50% { transform: scaleY(1.04) scaleX(0.96) translateY(-3px); }
75% { transform: scaleY(0.96) scaleX(1.02) translateY(1px); }
100% { transform: scaleY(1) translateY(0); }
}
.gallery-head {
width: 55px;
height: 55px;
border-radius: 12px;
border: 2px solid rgba(240, 246, 252, 0.1);
object-fit: cover;
background: #010409;
transition: all 0.3s ease;
opacity: 0.4;
filter: grayscale(80%);
}
.gallery-head.active {
opacity: 1;
filter: grayscale(0%);
border-color: var(--accent-color);
box-shadow: 0 0 15px rgba(88, 166, 255, 0.5);
transform-origin: bottom center;
}
@keyframes confused-shake {
0% { transform: translateX(0); }
25% { transform: translateX(-2px) rotate(-3deg); }
50% { transform: translateX(0); }
75% { transform: translateX(2px) rotate(3deg); }
100% { transform: translateX(0); }
}
.gallery-head-wrap[data-tooltip]::before {
content: attr(data-tooltip);
position: absolute;
bottom: 110%;
left: 50%;
transform: translateX(-50%);
background: rgba(13, 17, 23, 0.95);
color: #f0f6fc;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
white-space: pre-wrap;
width: 140px;
text-align: left;
border: 1px solid var(--border-color);
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.gallery-head-wrap:hover[data-tooltip]:not([data-tooltip=""])::before { opacity: 1; }
/* Yhteiset kuplasäännöt */
.gallery-head-wrap.state-question::after,
.gallery-head-wrap.state-alert::after,
.gallery-head-wrap.active:not(.state-question):not(.state-alert)::after {
position: absolute;
top: -10px;
right: -10px;
font-size: 14px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
z-index: 10;
}
/* State: Kysymys (Oranssi ?) */
.gallery-head-wrap.state-question::after {
content: '?';
color: #ffffff;
font-weight: 900;
font-family: system-ui, -apple-system, sans-serif;
animation: speech-pulse 1s infinite alternate;
background: #d29922; border: 1px solid #e3a830;
}
.gallery-head.state-question {
border-color: #d29922; box-shadow: 0 0 15px rgba(210, 153, 34, 0.4);
animation: confused-shake 2s infinite ease-in-out; filter: grayscale(10%); opacity: 0.9;
}
/* State: Alert (Punainen !) */
.gallery-head-wrap.state-alert::after {
content: '!';
color: #ffffff;
font-weight: 900;
font-family: system-ui, -apple-system, sans-serif;
animation: speech-pulse 0.5s infinite alternate;
background: #da3633; border: 1px solid #ff7b72;
}
.gallery-head.state-alert {
border-color: #ff4444; box-shadow: 0 0 15px rgba(255, 68, 68, 0.5);
animation: confused-shake 0.5s infinite; filter: grayscale(30%); opacity: 0.9;
}
.gallery-head-wrap { position: relative; display: inline-block; cursor: help; }
@keyframes speech-pulse {
0% { transform: scale(0.8) translateY(0); opacity: 0.6; }
50% { transform: scale(1.1) translateY(-2px); opacity: 1; }
100% { transform: scale(0.8) translateY(0); opacity: 0.6; }
}
.gallery-head-wrap.active:not(.state-question):not(.state-alert)::after {
content: '💬';
background: #0d1117;
border: 1px solid var(--accent-color);
}
.avatar-name { font-weight: 700; font-size: 10px; color: #f0f6fc; letter-spacing: 0.3px; margin-bottom: 0; }
.avatar-role { font-size: 10px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; line-height: 1.2; word-wrap: break-word; }
.agent-prompt-editor {
margin-top: 12px;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
display: none;
}
.agent-prompt-editor.visible { display: block; }
.agent-prompt-editor textarea {
width: 100%;
background: #010409;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-color);
font-size: 13px;
font-family: 'Courier New', monospace;
padding: 8px;
resize: vertical;
min-height: 40px;
outline: none;
}
.agent-prompt-editor textarea:focus { border-color: var(--accent-color); }
.agent-prompt-label {
font-size: 12px;
color: #8b949e;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.agent-prompt-label strong { color: var(--text-color); }
.lang-selector { display: flex; gap: 6px; background: #010409; padding: 4px; border-radius: 6px; border: 1px solid var(--border-color); }
.lang-btn { background: transparent; border: none; color: #8b949e; font-size: 11px; font-weight: 600; cursor: pointer; padding: 4px 8px; border-radius: 4px; transition: all 0.2s; }
.lang-btn:hover { color: #c9d1d9; }
.lang-btn.active { background: rgba(88, 166, 255, 0.15); color: var(--accent-color); }
@media (max-width: 768px) {
body { padding: 5px; margin: 0; }
.container { padding: 15px; border: none; border-radius: 0; border-bottom: 1px solid var(--border-color); }
.dashboard-panel { flex-direction: column; gap: 15px; padding: 10px; }
.stat-box { border-right: none !important; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
.stat-box:last-child { border-bottom: none; padding-bottom: 0; }
/* Typography & Header */
h1 { font-size: 22px; }
.sub { font-size: 11px; }
.lang-selector { flex-direction: column; gap: 4px; }
[style*="justify-content: space-between; align-items: flex-start"] { align-items: center !important; }
/* Tabs */
.main-tabs { display: flex; overflow-x: auto; white-space: nowrap; padding-bottom: 5px; margin-bottom: 15px; gap: 10px; }
.main-tab { padding: 8px 10px; font-size: 13px; text-align: center; }
/* Grid optimizations */
#task-selector { grid-template-columns: 1fr !important; }
#metrics-grid { grid-template-columns: 1fr 1fr !important; }
/* Org chart mobile tweaks */
.org-chart { padding: 6px; gap: 4px; }
.org-level { flex-wrap: wrap; gap: 4px !important; }
.org-connector { width: 12px; }
/* Avatar cards downscaling */
.avatar-card { width: 56px; padding: 4px 3px 2px; }
.avatar-card img { width: 36px; height: 36px; margin-bottom: 2px; border-radius: 8px; }
.avatar-name { font-size: 9px; margin-bottom: 0; }
/* User Input Area */
#user-input-box > div { flex-direction: column; }
#send-btn { width: 100%; padding: 12px; }
#code-input-container { flex-direction: column !important; }
#code-send-btn { width: 100%; margin-top: 5px; }
}
/* Responsiivinen korkeus */
@media (max-height: 900px) {
.terminal-panel { height: clamp(150px, 25vh, 250px); }
.agent-prompt-editor textarea { min-height: 30px; }
.container > div:first-child { margin-bottom: 6px; }
.container h1 { font-size: 22px; }
.container .sub { font-size: 12px; }
.avatar-card { padding: 4px 4px 2px; }
.avatar-card img { width: 40px; height: 40px; margin-bottom: 2px; }
}
@media (min-height: 1200px) {
.terminal-panel { height: clamp(350px, 40vh, 600px); }
.agent-prompt-editor textarea { min-height: 80px; }
}
</style>
</head>
<body>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
<div>
<h1 style="margin-bottom:0;" data-i18n="main_title"><span style="color:#ff6b00">Kipinä</span> <span>Agentic Playground</span></h1>
<p class="sub" style="margin-bottom:0;"><span data-i18n="main_subtitle">AI-ohjelmistokehitystiimi</span> · <span id="hub-version" style="color:#58a6ff">-</span></p>
</div>
<div class="lang-selector">
<button class="lang-btn active" onclick="setLanguage('fi')" data-lang="fi">FI</button>
<button class="lang-btn" onclick="setLanguage('se')" data-lang="se">SE</button>
<button class="lang-btn" onclick="setLanguage('en')" data-lang="en">EN</button>
</div>
</div>
<!-- Päävälilehdet -->
<div class="main-tabs">
<!-- Laskentaverkko ja Koodilaboratorio piilotettu (koodi säilytetty) -->
<div class="main-tab" onclick="switchMainTab('network')" data-i18n="tab_network">Laskentaverkko</div>
<div class="main-tab" onclick="switchMainTab('codelab')" data-i18n="tab_codelab">Koodilaboratorio</div>
<div class="main-tab active" onclick="switchMainTab('agents')" data-i18n="tab_agents">Kipinä Agentic Playground</div>
<div class="main-tab" onclick="switchMainTab('builder')" data-i18n="tab_builder">Agent Builder</div>
<div class="main-tab" onclick="switchMainTab('gallery')" data-i18n="tab_gallery">Galleria</div>
<div class="main-tab" onclick="switchMainTab('guide')" data-i18n="tab_guide">Opas</div>
</div>
<!-- PANEELI 1: Laskentaverkko -->
<div id="panel-network" class="main-panel">
<!-- Global Cluster Statistics (UI) -->
<div class="dashboard-panel">
<div class="stat-box" style="border-right: 1px solid #30363d;">
<h3 id="stat-nodes">0</h3>
<p data-i18n="stat_nodes_lbl">Aktiivisia Nodeja</p>
</div>
<div class="stat-box" style="border-right: 1px solid #30363d;">
<h3 id="stat-tasks">0</h3>
<p data-i18n="stat_tasks_lbl">Verkossa Suoritettua Tehtävää (Globaali)</p>
</div>
<div class="stat-box">
<h3 id="stat-vram">0 GB</h3>
<p data-i18n="stat_vram_lbl">Verkon yhteis-VRAM</p>
</div>
</div>
<div id="device-info" class="device-info"></div>
<div id="compat-banner" class="compat-banner"></div>
<div id="initial-state">
<!-- Tehtävävalitsin -->
<div style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px;text-align:left">
<div style="font-weight:600;font-size:15px;margin-bottom:12px" data-i18n="task_title">Valitse tehtävä</div>
<div id="task-selector" style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<label class="task-option selected" data-task="tokenize">
<input type="radio" name="task" value="tokenize" checked style="display:none">
<div class="task-title">Tokenisointivertailu</div>
<div class="task-desc">EN/FI-kieliparien tokenisointitehokkuuden vertailu Qwen2.5-tokenizeria käyttäen</div>
<div class="task-size">Lataus: ~7 MB (tokenizer)</div>
<span class="task-badge task-ready">Valmis</span>
<div class="task-info">
<strong>Miten tokenisaatio toimii?</strong>
Kielimallit eivät lue tekstiä kirjain kerrallaan. Sen sijaan teksti pilkotaan <em>tokeneiksi</em> — sanoja, tavuja tai sananosia, joista jokaisella on oma numerotunnisteensa mallin sanastossa.
<br><br>
Tokenizer on <em>BPE</em> (Byte Pair Encoding) -algoritmi: se yhdistää yleisimpiä merkkipareja isommiksi yksiköiksi. Englannissa "the" on yksi token, mutta suomessa "kirjoittamisen" voi olla 3-4 tokenia, koska tokenizer on koulutettu pääosin englanninkielisellä datalla.
<br><br>
<strong>Miksi tällä on väliä?</strong> Enemmän tokeneita = kalliimpaa ja hitaampaa. Sama lause suomeksi voi maksaa 50-100% enemmän tokeneita kuin englanniksi.
</div>
</label>
<label class="task-option" data-task="smollm-135m">
<input type="radio" name="task" value="smollm-135m" style="display:none">
<div class="task-title">SmolLM 135M</div>
<div class="task-desc">Kevyt kielimalli tekstigeneraatioon — sopii kaikille laitteille (CPU)</div>
<div class="task-size">Lataus: ~269 MB (safetensors) + 2 MB (tokenizer)</div>
<span class="task-badge task-ready">Valmis</span>
<div class="task-info">
<strong>SmolLM 135M</strong> (HuggingFace)
<br>Llama-arkkitehtuuri: 30 kerrosta, 576-dim embeddings, 9 attention-headiä.
<br><br>
135 miljoonaa parametria — noin 1000x pienempi kuin GPT-4. Silti kykenee yksinkertaiseen tekstigeneraatioon. Tämä malli mahtuu mihin tahansa laitteeseen ja pyörii kokonaan selaimessasi WebAssemblylla.
<br><br>
<strong>Miten inferenssi toimii?</strong> Malli ennustaa aina seuraavan tokenin edellisten perusteella (<em>autoregressive generation</em>). Jokainen token vaatii yhden "forward pass" -laskennan kaikkien kerrosten läpi. 135M-mallilla tämä kestää ~0.8s selaimessa ja ~90ms natiivisti.
</div>
</label>
<label class="task-option" data-task="qwen-05b">
<input type="radio" name="task" value="qwen-05b" style="display:none">
<div class="task-title">Qwen2.5 0.5B</div>
<div class="task-desc">Tehokkaampi kielimalli — vaatii vähintään 2 GB muistia (CPU)</div>
<div class="task-size">Lataus: ~990 MB (safetensors) + 7 MB (tokenizer)</div>
<span class="task-badge task-ready">Valmis</span>
<div class="task-info">
<strong>Qwen2.5 0.5B</strong> (Alibaba Cloud)
<br>24 kerrosta, 896-dim, 14 attention-headiä, 2 KV-headiä (GQA).
<br><br>
490 miljoonaa parametria ja 151 936 tokenin sanasto — 3x suurempi kuin SmolLM ja huomattavasti koherentimpi. <em>Grouped Query Attention</em> (GQA) vähentää muistinkäyttöä jakamalla key/value-headit 14:n query-headin kesken.
<br><br>
<strong>Miksi tämä on hitaampi?</strong> Jokaisessa kerroksessa lasketaan attention-matriisi (Q*K^T), joka skaalautuu O(n^2) sekvenssipituuden mukaan. 24 kerrosta x 14 headiä = 336 attention-laskentaa per token. Selaimessa CPU/Wasm: ~2.5s/token, natiivisti: ~90ms/token.
</div>
</label>
<label class="task-option" data-task="phi3-mini">
<input type="radio" name="task" value="phi3-mini" style="display:none">
<div class="task-title">Phi-3 Mini 3.8B</div>
<div class="task-desc">Iso kielimalli — vaatii native-noden (Docker + GPU)</div>
<div class="task-size">~7.6 GB — liian suuri selaimelle</div>
<span class="task-badge task-soon">Vain native</span>
<div class="task-info">
<strong>Phi-3 Mini 3.8B</strong> (Microsoft)
<br>32 kerrosta, 3072-dim, 32 attention-headiä.
<br><br>
3.8 miljardia parametria — luokassaan yksi tehokkaimmista. Microsoftin "small language model" -tutkimuksen tulos: laadukas koulutusdata kompensoi pientä mallikokoa. Pärjää monissa tehtävissä 7B-13B mallien tasolla.
<br><br>
<strong>Miksi ei pyöri selaimessa?</strong> F32-painot vaativat ~15 GB muistia. Selainten Wasm-muistiraja on tyypillisesti 4 GB. GPU-kiihdytyksellä (CUDA/ROCm) malli mahtuu 24 GB VRAM-näytönohjaimeen ja generoi ~50-100 tok/s.
</div>
</label>
</div>
</div>
<button id="start-btn" class="btn" data-i18n="btn_join">Liity laskentaverkkoon</button>
</div>
<div id="active-state" class="hidden">
<div id="download-bar" class="download-bar">
<div style="display:flex;justify-content:space-between;font-size:13px">
<span id="dl-label">Ladataan mallia...</span>
<span id="dl-pct" style="color:var(--accent-color);font-weight:600">0%</span>
</div>
<div class="bar-track"><div id="dl-fill" class="bar-fill" style="width:0%"></div></div>
<div id="dl-detail" style="font-size:11px;color:#8b949e;margin-top:4px">0 / 0 MB</div>
</div>
<!-- Resurssipaneeli -->
<div style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span style="font-weight:600;font-size:15px" data-i18n="resource_mgmt">Resurssien hallinta</span>
<span id="node-status" style="font-size:12px;color:#8b949e">Ei yhdistetty</span>
</div>
<!-- Kuormitussäädin -->
<div style="margin-bottom:14px">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px">
<span data-i18n="power_limiter">Laskentatehon rajoitin</span>
<strong id="load-display" style="color:var(--accent-color)">50%</strong>
</div>
<input type="range" id="gpu-load" min="0" max="100" value="50" style="width:100%;accent-color:var(--accent-color)">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#8b949e;margin-top:2px">
<span>Pysäytetty</span><span>Säästö</span><span>Tasapaino</span><span>Suorituskyky</span><span>Maksimi</span>
</div>
</div>
<!-- Automaattiset tehtävät -->
<div style="margin-bottom:14px;display:flex;align-items:center;gap:10px">
<label style="font-size:13px;display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="auto-tasks-toggle" checked style="accent-color:var(--accent-color)">
<span data-i18n="auto_tasks">Vastaanota automaattisia tehtäviä hubilta</span>
</label>
<span style="font-size:11px;color:#8b949e">(10s välein)</span>
</div>
<!-- Reaaliaikaiset metriikat -->
<div id="metrics-grid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:12px">
<div class="metric-card">
<div class="metric-val" id="m-tasks">0</div>
<div class="metric-label" data-i18n="metric_tasks">Tehtäviä</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-avg-time">-</div>
<div class="metric-label" data-i18n="metric_avg">Ka. aika</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-tokens">0</div>
<div class="metric-label" data-i18n="metric_tokens">Tokeneita</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-uptime">0s</div>
<div class="metric-label" data-i18n="metric_uptime">Käynnissä</div>
</div>
</div>
</div>
<div id="user-input-box" class="hidden" style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:12px;margin-bottom:12px">
<div style="font-size:13px;color:#8b949e;margin-bottom:8px" data-i18n="try_own_text">Kokeile omaa tekstiä:</div>
<div style="display:flex;gap:8px">
<input type="text" id="user-text" placeholder="Kirjoita teksti tokenisoitavaksi tai promptiksi..." style="flex:1;background:var(--panel-bg);border:1px solid var(--border-color);border-radius:4px;padding:8px 12px;color:var(--text-color);font-size:14px;outline:none">
<button id="send-btn" style="background:#238636;color:#fff;border:1px solid rgba(240,246,252,0.1);border-radius:4px;padding:8px 16px;font-size:14px;cursor:pointer;white-space:nowrap" data-i18n="btn_tokenize">Tokenisoi</button>
</div>
</div>
<div id="chat-box" class="chat-box hidden">
<div style="color: #8b949e; text-align: center; margin-top: 80px;">Odotetaan Generointitehtäviä Hubilta...</div>
</div>
<div id="log-box" class="status-box">
<p>> Odotetaan uusia tehtäviä Hubulta...</p>
</div>
</div>
</div><!-- /panel-network -->
<!-- PANEELI 2: Koodilaboratorio -->
<div id="panel-codelab" class="main-panel">
<div style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-weight:600;font-size:15px">Qwen2.5-Coder-0.5B-Instruct</span>
<span id="coder-status" style="font-size:12px;color:#8b949e">Ei yhdistetty</span>
</div>
<p style="font-size:12px;color:#8b949e;line-height:1.5;margin-bottom:12px">
Code-specialized language model trained on 5.5T tokens of source code.
Generates Python code in your browser via WebAssembly. Choose model size and write your own prompt.
</p>
<!-- Model size selector -->
<div style="display:flex;gap:8px;margin-bottom:10px">
<label style="flex:1;display:flex;align-items:center;gap:6px;background:var(--panel-bg);border:2px solid var(--accent-color);border-radius:4px;padding:8px 12px;cursor:pointer;font-size:13px" id="coder-opt-05b">
<input type="radio" name="coder-size" value="05b" checked style="accent-color:var(--accent-color)">
<div>
<strong style="color:var(--text-color)">0.5B</strong>
<span style="color:#8b949e"> — 990 MB, ~0.4 tok/s</span>
</div>
</label>
<label style="flex:1;display:flex;align-items:center;gap:6px;background:var(--panel-bg);border:2px solid var(--border-color);border-radius:4px;padding:8px 12px;cursor:pointer;font-size:13px" id="coder-opt-3b">
<input type="radio" name="coder-size" value="3b" style="accent-color:var(--accent-color)">
<div>
<strong style="color:var(--text-color)">3B</strong>
<span style="color:#8b949e"> — 6.2 GB, better quality, slower</span>
</div>
</label>
</div>
<div style="display:flex;gap:8px;align-items:start">
<div style="flex:1">
<div style="display:flex;gap:8px;margin-bottom:4px">
<input type="text" id="code-input" placeholder='e.g. Write a Python function that checks if a number is prime' style="flex:1;background:var(--panel-bg);border:1px solid var(--border-color);border-radius:4px;padding:8px 12px;color:var(--text-color);font-size:14px;outline:none;display:block" >
<textarea id="code-input-json" placeholder='{"prompt":"Write a fibonacci function","system":"You are a Python expert","max_tokens":128}' style="flex:1;background:var(--panel-bg);border:1px solid var(--border-color);border-radius:4px;padding:8px 12px;color:var(--text-color);font-size:13px;font-family:Courier New,monospace;outline:none;resize:vertical;min-height:60px;display:none"></textarea>
<button id="code-send-btn" style="background:#238636;color:#fff;border:1px solid rgba(240,246,252,0.1);border-radius:4px;padding:8px 16px;font-size:14px;cursor:pointer;align-self:stretch">Generate</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<label style="font-size:11px;color:#8b949e;cursor:pointer;display:flex;align-items:center;gap:4px">
<input type="checkbox" id="json-mode-toggle" style="accent-color:var(--accent-color)"> JSON mode
</label>
<details id="json-help" style="font-size:11px;color:#8b949e;display:none">
<summary style="cursor:pointer;color:var(--accent-color)">JSON syntax</summary>
<div style="background:#010409;border:1px solid var(--border-color);border-radius:4px;padding:10px;margin-top:6px;font-family:Courier New,monospace;font-size:12px;line-height:1.6;color:var(--text-color)">
{<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"prompt"</span>: <span style="color:#a5d6ff">"Write a bubble sort"</span>,<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"system"</span>: <span style="color:#a5d6ff">"You are a Python expert. Write only code."</span>,<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"max_tokens"</span>: <span style="color:#79c0ff">128</span>,<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"language"</span>: <span style="color:#a5d6ff">"python"</span><br>
}
<div style="margin-top:8px;color:#8b949e;font-family:sans-serif">
<strong style="color:var(--text-color)">Fields:</strong><br>
<code>prompt</code> (required) — the coding task<br>
<code>system</code> — system prompt override<br>
<code>max_tokens</code> — max tokens to generate (default: 128)<br>
<code>language</code> — hint for syntax highlighting
</div>
</div>
</details>
</div>
</div>
</div>
<div id="code-loading" style="display:none;margin-top:8px;font-size:12px;color:#d29922">Starting Coder model...</div>
</div>
<!-- Koodilaboratorion metriikat -->
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px">
<div class="metric-card">
<div class="metric-val" id="code-m-tasks">0</div>
<div class="metric-label">Tehtäviä</div>
</div>
<div class="metric-card">
<div class="metric-val" id="code-m-tokens">0</div>
<div class="metric-label">Tokeneita</div>
</div>
<div class="metric-card">
<div class="metric-val" id="code-m-speed">-</div>
<div class="metric-label">tok/s</div>
</div>
</div>
<!-- Latausvaiheet -->
<div id="code-pipeline" style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px;display:none">
<div style="font-size:13px;font-weight:600;margin-bottom:12px">Valmistautuminen</div>
<div id="code-steps" style="display:flex;flex-direction:column;gap:8px">
<div class="code-step" id="step-wasm">
<span class="step-icon">&#9711;</span>
<span>WebAssembly-ytimen lataus</span>
</div>
<div class="code-step" id="step-tokenizer">
<span class="step-icon">&#9711;</span>
<span>Tokenizer (7 MB)</span>
</div>
<div class="code-step" id="step-model">
<span class="step-icon">&#9711;</span>
<span>Qwen2.5-Coder-0.5B painot (990 MB)</span>
<span id="step-model-pct" style="color:var(--accent-color);margin-left:auto;font-size:12px"></span>
</div>
<div class="code-step" id="step-build">
<span class="step-icon">&#9711;</span>
<span>Mallin rakentaminen muistiin</span>
</div>
<div class="code-step" id="step-ready">
<span class="step-icon">&#9711;</span>
<span>Valmis generoimaan</span>
</div>
</div>
</div>
<!-- Kooditulokset -->
<div id="code-results" style="display:flex;flex-direction:column;gap:12px">
<div data-placeholder style="color:#8b949e;text-align:center;padding:40px">Kirjoita ohjelmointitehtävä ja paina Koodaa</div>
</div>
</div><!-- /panel-codelab -->
<!-- PANEELI 3: Agents & CLI -->
<div id="panel-agents" class="main-panel active" style="position: relative; border-radius: 6px;">
<div style="position: absolute; top:0; left:0; width:100%; height:100%; background: url('/avatars/forge_hero.svg') no-repeat center center; background-size: cover; opacity: 0.15; z-index: 0; pointer-events: none; border-radius: 6px;"></div>
<div style="background:rgba(13, 17, 23, 0.7); backdrop-filter: blur(4px); border:1px solid var(--border-color); border-radius:6px; padding:16px; margin-bottom:16px; position: relative; z-index: 1;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<div style="display:flex;align-items:center;gap:16px;">
<span style="font-weight:600;font-size:15px;color:var(--text-color)"><span style="color:#ff6b00">Kipinä</span> Agent Workspace</span>
<button id="btn-toggle-all" onclick="toggleAllAgents()" style="background:rgba(33, 38, 45, 0.8);border:1px solid var(--border-color);color:#c9d1d9;font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer;">Valitse kaikki</button>
<button id="btn-toggle-layout" onclick="toggleOrgLayout()" style="background:rgba(33, 38, 45, 0.8);border:1px solid var(--border-color);color:#c9d1d9;font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer;">⇅ Pysty</button>
</div>
<span id="agent-status" style="font-size:12px;color:var(--success-color)">Monitoring Active</span>
</div>
<div class="workspace-split" style="display:flex; gap:20px; align-items:flex-start; flex-wrap: wrap;">
<!-- LEFT COLUMN: Org chart & Prompt Editor -->
<div style="flex:1; min-width:300px; overflow-x:auto;">
<div class="org-chart">
<div class="org-level">
<div class="avatar-card" id="avatar-client" data-agent="client" onclick="selectAgent('client', event)">
<img src="/avatars/kettu_notext.png" alt="Asiakas">
<div class="avatar-name">Asiakas</div>
</div>
</div>
<div class="org-connector"></div>
<div class="org-level">
<div class="avatar-card" id="avatar-kpn" data-agent="manager" onclick="selectAgent('manager', event)">
<img src="/avatars/karhunpentu.png" alt="Manageri">
<div class="avatar-name">Manageri</div>
</div>
</div>
<div class="org-connector"></div>
<div class="org-level">
<div class="avatar-card" id="avatar-coder" data-agent="coder" onclick="selectAgent('coder', event)">
<img src="/avatars/kipina_notext.png" alt="Koodari">
<div class="avatar-name">Koodari</div>
</div>
<div class="avatar-card" id="avatar-data" data-agent="data" onclick="selectAgent('data', event)">
<img src="/avatars/pesukarhu_notext.png" alt="Data">
<div class="avatar-name">Data</div>
</div>
<div class="avatar-card" id="avatar-qa" data-agent="qa" onclick="selectAgent('qa', event)">
<img src="/avatars/susi_notext.png" alt="QA">
<div class="avatar-name">QA</div>
</div>
<div class="avatar-card" id="avatar-tester" data-agent="tester" onclick="selectAgent('tester', event)">
<img src="/avatars/laiskiainen_notext.png" alt="DevOps">
<div class="avatar-name">DevOps</div>
</div>
<div class="avatar-card" id="avatar-tofuist" data-agent="tofuist" onclick="selectAgent('tofuist', event)">
<img src="/avatars/gecko_notext.png" alt="Tofuist">
<div class="avatar-name">Tofuist</div>
</div>
<div class="avatar-card" id="avatar-observer" data-agent="observer" onclick="selectAgent('observer', event)">
<img src="/avatars/aikuinen_susi.png" alt="Tarkkailija">
<div class="avatar-name">Tarkkailija</div>
</div>
</div>
</div>
<!-- Prompt Editor -->
<div class="agent-prompt-editor" id="agent-prompt-editor" style="margin-top:10px;">
<div class="agent-prompt-label">
<strong id="agent-prompt-name"></strong>
<span id="agent-prompt-saved" style="color:var(--success-color);opacity:0;transition:opacity 0.3s">Tallennettu</span>
</div>
<textarea id="agent-prompt-text" placeholder="Kirjoita system prompt..."></textarea>
<div id="shared-prompt-section" style="display:none;margin-top:8px;font-size:12px;color:#8b949e">
Yhteinen konteksti liitetään jokaisen valitun agentin oman promptin alkuun.
</div>
<div id="agent-last-prompt" style="display:none;margin-top:8px">
<button id="agent-open-modal-btn" style="background:#161b22;border:1px solid var(--accent-color);color:var(--accent-color);font-size:12px;padding:4px 12px;border-radius:4px;cursor:pointer;width:100%">📋 Näytä viimeisin prompti</button>
</div>
</div>
</div>
<!-- RIGHT COLUMN: Puhuvat Päät Gallery (piilotettu, koodi säilytetty) -->
<div style="flex-basis:150px; flex-shrink:0; display:none;">
<div style="background:rgba(1, 4, 9, 0.6); border:1px solid var(--border-color); border-radius:6px; padding:12px; height: 100%;">
<div id="all-heads-gallery" style="display:flex; flex-wrap:wrap; gap:10px; justify-content:center;">
<div class="gallery-head-wrap" id="wrap-client"><img src="/avatars/kettu_notext.png" id="gallery-client" class="gallery-head" alt="Asiakas"></div>
<div class="gallery-head-wrap" id="wrap-observer"><img src="/avatars/aikuinen_susi.png" id="gallery-observer" class="gallery-head" alt="Tarkkailija"></div>
<div class="gallery-head-wrap" id="wrap-manager"><img src="/avatars/karhunpentu.png" id="gallery-manager" class="gallery-head" alt="Manageri"></div>
<div class="gallery-head-wrap" id="wrap-coder"><img src="/avatars/kipina_notext.png" id="gallery-coder" class="gallery-head" alt="Koodari"></div>
<div class="gallery-head-wrap" id="wrap-data"><img src="/avatars/pesukarhu_notext.png" id="gallery-data" class="gallery-head" alt="Data"></div>
<div class="gallery-head-wrap" id="wrap-qa"><img src="/avatars/susi_notext.png" id="gallery-qa" class="gallery-head" alt="QA"></div>
<div class="gallery-head-wrap" id="wrap-tester"><img src="/avatars/laiskiainen_notext.png" id="gallery-tester" class="gallery-head" alt="DevOps"></div>
<div class="gallery-head-wrap" id="wrap-tofuist"><img src="/avatars/gecko_notext.png" id="gallery-tofuist" class="gallery-head" alt="Tofuist"></div>
</div>
<div style="text-align: center; margin-top: 16px;">
<button class="btn" id="simu-btn" onclick="toggleSimulation()" style="font-size: 11px; padding: 4px 10px; background: #0d1a2d; border-color: #58a6ff;">Käynnistä simulaatio</button>
</div>
</div>
</div>
</div>
<div id="agent-hub-status" style="margin-top:20px;padding:8px 14px;background:#0d1117;border:1px solid var(--border-color);border-radius:6px 6px 0 0;font-family:'Courier New',monospace;font-size:13px;display:flex;align-items:center;gap:12px;cursor:help" title="WebSocket-yhteys Kipinä Hubiin — hallitsee tehtävien jakelun ja solmujen koordinoinnin">
<span style="display:flex;align-items:center;gap:6px" title="Hub-yhteyden tila">
<span id="agent-hub-dot" style="width:8px;height:8px;border-radius:50%;background:#d29922;display:inline-block"></span>
<span style="color:#8b949e">Hub:</span>
<span id="agent-hub-label" style="color:#d29922">Yhdistetään...</span>
</span>
<span style="color:#30363d"></span>
<span style="display:flex;align-items:center;gap:6px" id="agent-compute-wrap">
<span id="agent-compute-dot" style="width:8px;height:8px;border-radius:50%;background:#30363d;display:inline-block"></span>
<span style="color:#8b949e">Laskenta:</span>
<span id="agent-compute-label" style="color:#8b949e"></span>
<button id="agent-compute-btn" style="margin-left:4px;padding:2px 10px;border-radius:4px;border:1px solid #30363d;background:#161b22;color:#58a6ff;font-size:12px;font-family:inherit;cursor:pointer" title="Käynnistä kielimalli omalla koneellasi laskentaa varten">Alusta laskentasolmu</button>
</span>
</div>
<div id="pipeline-steps" style="display:none;background:#0d1117;border:1px solid var(--border-color);border-top:none;padding:8px 14px;font-family:'Courier New',monospace;font-size:12px;line-height:1.8;flex-wrap:wrap;display:flex;gap:2px"></div>
<div class="terminal-panel" id="agent-terminal" style="margin-top:0;border-top:none;border-radius:0">
</div>
<div style="position:relative;display:flex;align-items:center;background:#010409;border:1px solid var(--border-color);border-top:none;border-radius:0 0 6px 6px;padding:8px 12px;font-family:'Courier New',monospace;font-size:14px">
<span style="color:#d29922;margin-right:8px;flex-shrink:0">$</span>
<input id="term-input" type="text" placeholder="kpn run coder &quot;kirjoita hello world&quot;" spellcheck="false" autocomplete="off"
style="flex:1;background:transparent;border:none;outline:none;color:var(--success-color);font-family:inherit;font-size:inherit">
<div id="term-dropdown" style="display:none;position:absolute;bottom:100%;left:30px;background:#161b22;border:1px solid #30363d;border-radius:6px;max-height:200px;overflow-y:auto;font-size:13px;min-width:200px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,0.4)"></div>
</div>
</div>
</div><!-- /panel-agents -->
<!-- PANEELI 4: Opas -->
<div id="panel-builder" class="main-panel">
<div style="max-width:800px;margin:0 auto;padding:20px">
<h2 style="color:#f0f6fc;margin-bottom:16px">Agent Builder</h2>
<p style="color:#8b949e;margin-bottom:20px">Luo ja muokkaa agentteja. Agentit tallentuvat palvelimelle ja ovat kaikkien käytettävissä.</p>
<!-- Agenttilista -->
<div id="builder-agent-list" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-bottom:24px"></div>
<!-- Lomake -->
<div id="builder-form" style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;display:none">
<div style="display:grid;grid-template-columns:auto 1fr;gap:16px;align-items:start">
<!-- Avatar-valitsin -->
<div>
<img id="builder-avatar-preview" src="/avatars/kipina_notext.png" style="width:80px;height:80px;border-radius:16px;border:2px solid #30363d;cursor:pointer;object-fit:cover" onclick="document.getElementById('builder-avatar-select').style.display=document.getElementById('builder-avatar-select').style.display==='none'?'flex':'none'">
<div id="builder-avatar-select" style="display:none;flex-wrap:wrap;gap:6px;margin-top:8px;max-width:200px"></div>
</div>
<!-- Kentät -->
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;gap:10px">
<input id="builder-id" placeholder="tunniste (esim. tofuist)" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px">
<input id="builder-name" placeholder="Näyttönimi" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px">
</div>
<div style="display:flex;gap:10px">
<select id="builder-role" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px">
<option value="coder">Koodari</option>
<option value="qa">QA / Testaus</option>
<option value="devops">DevOps</option>
<option value="devsecops">DevSecOps</option>
<option value="architect">Arkkitehti</option>
<option value="iac">IaC / Infra</option>
<option value="data">Data</option>
<option value="manager">Manageri</option>
<option value="writer">Kirjoittaja</option>
<option value="custom">Vapaa</option>
</select>
<div style="flex:1;position:relative">
<input id="builder-model" placeholder="Malli (esim. qwen2.5-coder:7b)" value="qwen2.5-coder:7b" style="width:100%;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px" class="builder-tip" data-tip="Ollama-mallin eksakti nimi.
Esimerkkejä:
• qwen2.5-coder:7b (oletus, hyvä koodaamiseen)
• qwen2.5-coder:1.5b (kevyempi, nopeampi)
• deepseek-r1 (reasoning, hidas mutta laadukas)
• llama3.2:3b (yleiskäyttöinen)
Malli pitää olla ladattuna Ollamaan (ollama pull).">
</div>
</div>
<div style="display:flex;gap:10px">
<input id="builder-color" type="color" value="#3fb950" style="width:40px;height:32px;border:1px solid #30363d;border-radius:4px;background:#0d1117;cursor:pointer">
<input id="builder-docs" placeholder="Docs URL (valinnainen, esim. /docs/tofu-cheatsheet.md)" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px" class="builder-tip" data-tip="Referenssidokumentti joka liitetään agentin promptiin automaattisesti.
Esim. /docs/tofu-cheatsheet.md
Dokumentti ladataan kerran ja cachetetaan.
Max ~10 KB — pidempi teksti vie konteksti-ikkunaa.
Tiedoston tulee olla .md tai .txt palvelimella.">
</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<label style="color:#8b949e;font-size:12px;font-weight:600">System Prompt</label>
<span class="builder-tip" data-tip="Prompti on agentin 'persoonallisuus' — se ohjaa miten malli vastaa.
Kirjoita englanniksi (tehokkaampi tokenisointi, parempi ymmärrys).
Hyvä prompti:
• Määrittele rooli: 'You are a senior Python developer'
• Ole konkreettinen: 'Always use type hints and docstrings'
• Mainitse kirjastot: 'Use FastAPI, SQLAlchemy, Pydantic'
• Rajoita: 'Output ONLY code, no explanations'
Huono prompti:
• Liian yleinen: 'Olet koodari'
• Suomeksi (kuluttaa enemmän tokeneita)
• Liian pitkä (vie tilaa vastaukselta)
Prompti lähetetään jokaisessa kutsussa ennen käyttäjän viestiä.
Max ~100 sanaa — pidempi vie konteksti-ikkunaa." style="color:#484f58;cursor:help;font-size:13px">?</span>
</div>
<textarea id="builder-prompt" rows="4" placeholder="You are a senior developer specializing in..." style="background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:8px 10px;color:#c9d1d9;font-size:13px;resize:vertical;font-family:inherit;width:100%"></textarea>
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label style="color:#8b949e;font-size:12px" class="builder-tip" data-tip="Temperature: kuinka 'luova' malli on.
0.0 = aina sama vastaus (deterministic)
0.30.5 = tarkka koodi, boilerplate
0.7 = oletus, hyvä kompromissi
1.0+ = luova, vaihteleva, riskialtis">Temp: <span style="color:#484f58;cursor:help">?</span></label>
<input id="builder-temp" type="number" value="0.7" min="0" max="2" step="0.1" style="width:60px;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:4px 8px;color:#c9d1d9;font-size:12px">
<label style="color:#8b949e;font-size:12px" class="builder-tip" data-tip="Top-k: montako todennäköisintä tokenia on valittavissa.
1 = greedy (aina paras)
40 = oletus, järkevät vaihtoehdot
100+ = laajempi valikoima, enemmän vaihtelua">Top-k: <span style="color:#484f58;cursor:help">?</span></label>
<input id="builder-topk" type="number" value="40" min="1" max="200" style="width:60px;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:4px 8px;color:#c9d1d9;font-size:12px">
<label style="color:#8b949e;font-size:12px" class="builder-tip" data-tip="Max tokens: vastauksen enimmäispituus.
128 = lyhyt vastaus (review, suunnitelma)
512 = oletus, riittää useimpiin tiedostoihin
1024+ = pitkä koodi, mutta hitaampi">Max tok: <span style="color:#484f58;cursor:help">?</span></label>
<input id="builder-maxtokens" type="number" value="512" min="32" max="4096" step="32" style="width:70px;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:4px 8px;color:#c9d1d9;font-size:12px">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="builderSave()" style="background:#238636;border:1px solid #2ea043;color:#fff;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px">Tallenna</button>
<button onclick="builderDelete()" id="builder-delete-btn" style="background:#21262d;border:1px solid #f85149;color:#f85149;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px;display:none">Poista</button>
<button onclick="builderCancel()" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px">Peruuta</button>
</div>
</div>
</div>
</div>
<!-- Napit -->
<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap">
<button onclick="builderNew()" id="builder-new-btn" style="background:#238636;border:1px solid #2ea043;color:#fff;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:14px">+ Uusi agentti</button>
<button onclick="builderExportCrewAI()" style="background:#0d1117;border:1px solid #58a6ff;color:#58a6ff;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:14px" class="builder-tip" data-tip="Generoi koko agenttitiimistä CrewAI Python -projektin
ZIP-paketti sisältäen:
• crew.py — agentit, tehtävät ja pipeline
• Dockerfile + docker-compose.yml
• pyproject.toml riippuvuudet
• .env mallipohja">Export CrewAI</button>
</div>
</div>
</div>
<div id="panel-gallery" class="main-panel">
<div style="max-width:900px;margin:0 auto;padding:20px">
<h2 style="color:#f0f6fc;margin-bottom:8px">Hahmogalleria</h2>
<p style="color:#8b949e;margin-bottom:20px">Valmiit avatarit agenteille. Klikkaa kopioidaksesi polun.</p>
<div id="gallery-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:16px"></div>
</div>
</div>
<div id="panel-guide" class="main-panel">
<div id="guide-content" style="max-width:800px;margin:0 auto;padding:20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:var(--text-color);line-height:1.7;font-size:15px">
<p style="color:#8b949e">Ladataan opasta...</p>
</div>
</div>
</div>
<script type="module">
import init, { start_agent_node, set_gpu_load, set_auto_tasks } from '/pkg/node.js';
// HTML-escape kaikelle käyttäjä-/backendidatalle joka menee innerHTML:ään
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Poistaa system-promptin näkyvästä prompt-tekstistä (agents-pipeline lisää sen alkuun)
function stripSystemPrompt(prompt) {
if (!prompt) return '';
// Poistetaan kaikki ennen viimeistä kappaletta (system + agent promptit erotettu \n\n:llä)
const parts = prompt.split('\n\n');
return parts[parts.length - 1] || prompt;
}
// Agenttien system promptit
const agentPrompts = {
client: { name: 'Asiakas — Projektin vaatimukset', model: 'user-input', default: 'Kirjoita tähän asiakkaan toiveet ja projektin vaatimukset. Orkestraattori (Manageri) purkaa ja delegoi nämä työt asiantuntijoille.' },
observer: { name: 'Tarkkailija — System Prompt', model: 'deepseek-r1', default: 'Olet ohjelmistoprojektin riippumaton valvoja. Sinulla on täysi pääsy kaikkiin projektin tietoihin ja muiden agenttien keskusteluihin. Valvo tiimin (Manageri, Koodari, Data, QA, DevOps) toimintaa asiantuntijana kokonaisuutena ja huomauta välittömästi visio- tai turvallisuusriskeistä.' },
manager: { name: 'Manageri — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet projektipäällikkö. Jaa tehtävät osiin, priorisoi ja koordinoi tiimin työtä.' },
coder: { name: 'Koodari — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet kokenut ohjelmistokehittäjä. Kirjoita selkeää, testattavaa koodia ja vastaa aina koodilla.' },
data: { name: 'Data-Agentti — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet tietokanta-asiantuntija. Vastaat skeemojen suunnittelusta, SQL-kyselyiden optimoinnista ja datamalleista.' },
qa: { name: 'QA — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet laadunvarmistaja (QA). Kirjoitat testejä, etsit virheitä ja varmistat, että kaikki reunatapaukset on huomioitu.' },
tester: { name: 'DevOps — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet DevOps-insinööri. Kirjoitat Dockerfile- ja docker-compose.yml-tiedostot, README:t ja käynnistysohjeet. Käytä aina multi-stage Docker buildia ja docker compose -orkestrointia.' },
tofuist: { name: 'Tofuist — System Prompt', model: 'qwen2.5-coder:7b', docs: '/docs/tofu-cheatsheet.md', default: 'You are an OpenTofu/Terraform IaC specialist. You write HCL infrastructure code: providers, resources, modules, variables, outputs, state management, and encryption. You follow OpenTofu best practices: use planning behaviors before apply, handle resource lifecycle (create_before_destroy, prevent_destroy), configure state encryption for sensitive data, and structure code with clear module boundaries. Always output valid HCL code. Use provider references correctly (required_providers block). Prefer data sources over hardcoded values.' },
};
const selectedAgents = new Set();
let sharedPrompt = localStorage.getItem('kpn-shared-prompt') || '';
// Ladataan tallennetut promptit localStoragesta
for (const key of Object.keys(agentPrompts)) {
const saved = localStorage.getItem('kpn-agent-prompt-' + key);
if (saved) agentPrompts[key].prompt = saved;
else agentPrompts[key].prompt = agentPrompts[key].default;
}
function updatePromptEditor() {
const editor = document.getElementById('agent-prompt-editor');
const nameEl = document.getElementById('agent-prompt-name');
const textEl = document.getElementById('agent-prompt-text');
const sharedEl = document.getElementById('shared-prompt-section');
const btnAll = document.getElementById('btn-toggle-all');
const t = window.currentLangDict || { btn_clear_all: 'Tyhjennä valinnat', btn_select_all: 'Valitse kaikki' };
if (btnAll) {
if (selectedAgents.size === Object.keys(agentPrompts).length) {
btnAll.textContent = t.btn_clear_all;
} else {
btnAll.textContent = t.btn_select_all;
}
}
// Piilotettu ominaisuus: Puhuvien videoiden / gif-animaatioiden kytkentä
// Aseta true kun olet luonut _puhuva.gif tiedostot /avatars -kansioon
window.USE_ANIMATED_GIFS = false;
// Update gallery heads
document.querySelectorAll('.gallery-head').forEach(el => {
el.classList.remove('active');
if (window.USE_ANIMATED_GIFS) {
el.src = el.src.replace('_puhuva.gif', '.png');
}
});
document.querySelectorAll('.gallery-head-wrap').forEach(el => el.classList.remove('active'));
selectedAgents.forEach(agent => {
const gel = document.getElementById('gallery-' + agent);
const wrap = document.getElementById('wrap-' + agent);
if (gel) {
gel.classList.add('active');
if (window.USE_ANIMATED_GIFS) {
gel.src = gel.src.replace('.png', '_puhuva.gif');
}
}
if (wrap) wrap.classList.add('active');
});
checkAgentConfusion();
if (selectedAgents.size === 0) {
editor.classList.remove('visible');
return;
}
editor.classList.add('visible');
if (selectedAgents.size === 1) {
const agent = [...selectedAgents][0];
const cfg = agentPrompts[agent];
nameEl.textContent = cfg.name;
textEl.value = cfg.prompt;
sharedEl.style.display = 'none';
} else {
const isAll = selectedAgents.size === Object.keys(agentPrompts).length;
const lang = localStorage.getItem('kpn_lang') || 'fi';
let title = "";
if (isAll) {
title = lang === 'fi' ? 'Kaikki agentit — Yhteinen konteksti' : (lang === 'se' ? 'Alla agenter — Delad kontext' : 'All agents — Shared context');
} else if (selectedAgents.size > 2) {
title = lang === 'fi' ? `${selectedAgents.size} agenttia — Yhteinen konteksti` : (lang === 'se' ? `${selectedAgents.size} agenter — Delad kontext` : `${selectedAgents.size} agents — Shared context`);
} else {
const names = [...selectedAgents].map(a => agentPrompts[a].name.split(' — ')[0]);
const suffix = lang === 'fi' ? ' — Yhteinen konteksti' : (lang === 'se' ? ' — Delad kontext' : ' — Shared context');
title = names.join(' + ') + suffix;
}
nameEl.textContent = title;
textEl.value = sharedPrompt;
sharedEl.style.display = 'block';
}
// Näytetään viimeisin pipeline-prompti valitulle agentille
const lastPromptDiv = document.getElementById('agent-last-prompt');
const lastPromptText = document.getElementById('agent-last-prompt-text');
if (selectedAgents.size === 1) {
const agent = [...selectedAgents][0];
// Näytetään aina jos agentilla on oletusprompt tai pipeline-historia
const hasPrompt = defaultPipelinePrompts[agent] || pipelineSteps.some(s => s.agent === agent);
lastPromptDiv.style.display = hasPrompt ? 'block' : 'none';
} else {
lastPromptDiv.style.display = 'none';
}
}
window.selectAgent = function(agent, e) {
const isShift = e && e.shiftKey;
if (isShift) {
// Shift+klikkaus: lisää/poista multi-selectistä
const card = document.querySelector(`[data-agent="${agent}"]`);
if (selectedAgents.has(agent)) {
selectedAgents.delete(agent);
card?.classList.remove('selected');
card?.classList.remove('active');
} else {
selectedAgents.add(agent);
card?.classList.add('selected');
}
} else {
// Normaali klikkaus: valitse yksi (poista muut)
const wasSelected = selectedAgents.has(agent) && selectedAgents.size === 1;
// Poistetaan kaikki valinnat
document.querySelectorAll('.avatar-card').forEach(c => {
c.classList.remove('selected');
c.classList.remove('active');
});
selectedAgents.clear();
if (!wasSelected) {
// Valitaan klikattu
selectedAgents.add(agent);
const card = document.querySelector(`[data-agent="${agent}"]`);
card?.classList.add('selected');
}
}
updatePromptEditor();
if (selectedAgents.size > 0) {
document.getElementById('agent-prompt-text')?.focus();
}
};
window.toggleAllAgents = function() {
const allAgents = Object.keys(agentPrompts);
if (selectedAgents.size === allAgents.length) {
selectedAgents.clear();
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('selected', 'active'));
} else {
allAgents.forEach(a => {
selectedAgents.add(a);
const card = document.querySelector(`[data-agent="${a}"]`);
if (card) card.classList.add('selected');
});
}
updatePromptEditor();
};
window.toggleOrgLayout = function() {
const chart = document.querySelector('.org-chart');
const btn = document.getElementById('btn-toggle-layout');
if (chart.classList.toggle('vertical')) {
btn.textContent = '⇄ Vaaka';
} else {
btn.textContent = '⇅ Pysty';
}
};
// Autosave prompti
document.getElementById('agent-prompt-text')?.addEventListener('input', (e) => {
if (selectedAgents.size === 0) return;
const saved = document.getElementById('agent-prompt-saved');
if (selectedAgents.size === 1) {
const agent = [...selectedAgents][0];
agentPrompts[agent].prompt = e.target.value;
localStorage.setItem('kpn-agent-prompt-' + agent, e.target.value);
} else {
sharedPrompt = e.target.value;
localStorage.setItem('kpn-shared-prompt', e.target.value);
}
checkAgentConfusion();
saved.style.opacity = '1';
clearTimeout(saved._t);
saved._t = setTimeout(() => saved.style.opacity = '0', 1500);
});
// Prompt-editori modal
let modalAgent = null;
let modalPromptParts = [];
function parsePromptToFields(prompt) {
// Pilkotaan prompti avain-arvo-pareiksi
const fields = [];
const lines = prompt.split('\n');
let currentKey = null;
let currentVal = [];
for (const line of lines) {
// Tunnistetaan avain: KEYWORD: tai KEYWORD — tai rivin alku isolla
const keyMatch = line.match(/^(Project|CONSTRAINTS|EXAMPLE|RULES|IMPORTANT|Check|Files in project|Main app|Already written files|PROJECT CODE|Current code|Review feedback|Feedback)[\s:—]*(.*)/i);
if (keyMatch) {
if (currentKey) fields.push({ key: currentKey, value: currentVal.join('\n').trim(), editable: isEditable(currentKey) });
currentKey = keyMatch[1];
currentVal = keyMatch[2] ? [keyMatch[2]] : [];
} else {
currentVal.push(line);
}
}
if (currentKey) fields.push({ key: currentKey, value: currentVal.join('\n').trim(), editable: isEditable(currentKey) });
// Jos ei löytynyt rakenteellisia avaimia, näytetään koko prompti yhtenä
if (fields.length === 0) fields.push({ key: 'Prompti', value: prompt, editable: true });
return fields;
}
function isEditable(key) {
const editableKeys = ['Project', 'CONSTRAINTS', 'IMPORTANT', 'Feedback', 'Review feedback'];
return editableKeys.some(k => key.toLowerCase().includes(k.toLowerCase()));
}
function openPromptModal(agent, label, prompt) {
modalAgent = agent;
modalPromptParts = parsePromptToFields(prompt);
const modal = document.getElementById('prompt-modal');
const title = document.getElementById('prompt-modal-title');
const fields = document.getElementById('prompt-modal-fields');
const agentNames = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data', tofuist: 'Tofuist' };
title.textContent = `${agentNames[agent] || agent}${label}`;
fields.innerHTML = modalPromptParts.map((f, i) => `
<div style="border:1px solid #21262d;border-radius:6px;overflow:hidden">
<div style="background:#161b22;padding:6px 10px;font-size:12px;font-weight:600;color:${f.editable ? '#58a6ff' : '#8b949e'};display:flex;align-items:center;gap:6px">
${f.editable ? '✏️' : '🔒'} ${f.key}
</div>
<textarea data-field-idx="${i}" ${f.editable ? '' : 'readonly'} style="width:100%;background:${f.editable ? '#010409' : '#0d1117'};border:none;color:${f.editable ? '#c9d1d9' : '#6e7681'};font-size:12px;font-family:'Courier New',monospace;padding:8px;resize:vertical;min-height:${f.value.split('\n').length > 3 ? '100' : '40'}px;outline:none;box-sizing:border-box">${f.value.replace(/</g,'&lt;')}</textarea>
</div>
`).join('');
modal.style.display = 'block';
// Sulje klikatessa taustaa
modal.onclick = (e) => { if (e.target === modal) closePromptModal(); };
}
window.openPromptModal = openPromptModal;
function closePromptModal() {
// Tallennetaan muokattu prompti localStorageen
if (modalAgent) {
const fields = document.getElementById('prompt-modal-fields');
const textareas = fields.querySelectorAll('textarea');
const parts = [];
textareas.forEach((ta, i) => {
const key = modalPromptParts[i]?.key || '';
parts.push(`${key}: ${ta.value.trim()}`);
});
const assembled = parts.join('\n\n');
if (defaultPipelinePrompts[modalAgent]) {
defaultPipelinePrompts[modalAgent].prompt = assembled;
localStorage.setItem('kpn-pipeline-prompt-' + modalAgent, assembled);
}
}
document.getElementById('prompt-modal').style.display = 'none';
}
window.closePromptModal = closePromptModal;
function rerunFromModal() {
// Kootaan prompti takaisin kentistä
const fields = document.getElementById('prompt-modal-fields');
const textareas = fields.querySelectorAll('textarea');
const parts = [];
textareas.forEach((ta, i) => {
const key = modalPromptParts[i]?.key || '';
const val = ta.value.trim();
if (val) parts.push(`${key}: ${val}`);
});
const prompt = parts.join('\n\n');
const model = agentPrompts[modalAgent]?.model || 'qwen2.5-coder:7b';
closePromptModal();
termLog(`<span class="terminal-prompt">$</span> <span style="color:#a371f7">↻ Aja uudelleen:</span> ${esc(modalAgent)}`);
kpnRun(model, prompt);
}
window.rerunFromModal = rerunFromModal;
// "Näytä prompti" -nappi avaa modalin
// Oletuspromptit jokaiselle agentille — näkyvät aina, muokattavissa
const defaultPipelinePrompts = {
manager: { label: 'Suunnittelu', prompt: `List the source files needed for this project. One file per line, format:
filename.py: one-line description
CONSTRAINTS: the coder can only generate ~400 tokens per file
- Max 3 files (keep it minimal)
- Each file must be SHORT: one clear responsibility, no boilerplate
- Only .py, .html and pyproject.toml files
- If the project has a UI, include one index.html served by FastAPI at /
EXAMPLE: for "FastAPI todo app with SQLite"
pyproject.toml: project metadata and dependencies
models.py: SQLAlchemy models and database setup
main.py: FastAPI app with CRUD endpoints and serves index.html at /
index.html: simple HTML UI with forms and fetch() to call the API
Project: (käyttäjän kuvaus)` },
coder: { label: 'Koodaus', prompt: `Project: (managerin suunnitelma)
Write ONLY the file "filename.py": description
EXAMPLE: output for a main.py
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from models import get_db, User
app = FastAPI()
@app.get("/users")
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
IMPORTANT: Keep the code SHORT. Max ~50 lines. No comments, no docstrings. Output ONLY code.` },
qa: { label: 'Testit + Validointi', prompt: `Write test_app.py using pytest and FastAPI TestClient. Max 3 tests. Output ONLY code.
EXAMPLE:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create():
r = client.post("/users", params={"name": "Test"})
assert r.status_code == 200
IMPORTANT: Check consistency
1. Dockerfile COPY: references files that exist
2. Dockerfile deps vs imports: all imports covered
3. docker-compose ports: match EXPOSE
4. Test imports: match actual module names
5. pyproject.toml deps: cover all imports (but NOT stdlib like sqlite3, os, sys, json)` },
tester: { label: 'DevOps', prompt: `docker-compose.yml: services, volumes, port mappings
EXAMPLE:
services:
app:
build: .
ports:
- "8000:8000"
restart: unless-stopped
README.md: Quick start, development, API endpoints, testing
IMPORTANT: Use uv for package management (uv sync, uv run)` },
data: { label: 'Data', prompt: `SQLAlchemy models and database setup.
EXAMPLE:
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_engine("sqlite:///app.db")
class Base(DeclarativeBase): pass
IMPORTANT: Include get_db() dependency for FastAPI` },
};
// Ladataan muokatut promptit localStoragesta
for (const [agent, def] of Object.entries(defaultPipelinePrompts)) {
const saved = localStorage.getItem('kpn-pipeline-prompt-' + agent);
if (saved) def.prompt = saved;
}
document.getElementById('agent-open-modal-btn')?.addEventListener('click', () => {
if (selectedAgents.size !== 1) return;
const agent = [...selectedAgents][0];
// Viimeisin pipeline-prompti tai oletusprompt
const lastStep = [...pipelineSteps].reverse().find(s => s.agent === agent && s.status === 'done' && s.input);
const def = defaultPipelinePrompts[agent];
const prompt = lastStep?.input || def?.prompt || agentPrompts[agent]?.prompt || '';
const label = lastStep?.label || def?.label || 'Prompti';
openPromptModal(agent, label, prompt);
});
function checkAgentConfusion() {
Object.keys(agentPrompts).forEach(agent => {
const prompt = agentPrompts[agent].prompt || "";
const wrap = document.getElementById('wrap-' + agent);
const gel = document.getElementById('gallery-' + agent);
if (!wrap || !gel) return;
// Nollataan tilat
wrap.classList.remove('state-question', 'state-alert');
gel.classList.remove('state-question', 'state-alert');
wrap.removeAttribute('data-tooltip');
const pLow = prompt.toLowerCase();
const agentTitle = (agentPrompts[agent].name.split(' — ')[0] || "AGENTTI").toUpperCase();
// Hälytys / Virhe
if (pLow.includes('todo') || pLow.includes('viallinen')) {
wrap.classList.add('state-alert');
gel.classList.add('state-alert');
wrap.setAttribute('data-tooltip', `${agentTitle}: "Kriittinen virhe ohjeistuksessa!"\n(Koodissa tai promptissa esiintyy teksti TODO tai viallinen. Korjaa ohje.)`);
}
// Kysyttävää / Hämmennys
else if (prompt.trim().length <= 15 || prompt.includes('?')) {
wrap.classList.add('state-question');
gel.classList.add('state-question');
const questions = {
client: 'Millaisia uusia ominaisuuksia tuotteessa pitäisi olla?',
manager: 'Kuka asiantuntijoista ottaa vastuulleen tämän taskin?',
coder: 'Käytetäänkö tässä komponentissa uusinta React-ohjetta?',
data: 'Millainen tietorakenne käyttäjästä tallennetaan kantaan?',
qa: 'Onko tälle koodille olemassa jo kattavat yksikkötestit?',
tester: 'Mihin haluaisit julkaista tämän laiteympäristön version?',
observer: 'Mitä laatumetriikkoja minun tulisi ensisijaisesti painottaa?'
};
const exampleQ = questions[agent] || 'Mitä minun pitäisi tehdä seuraavaksi?';
const reason = prompt.trim().length <= 15 ? 'Määrittely on tällä hetkellä liian lyhyt.' : 'Ohje on jätetty avoimeksi (? -merkki).';
wrap.setAttribute('data-tooltip', `[?] ${agentTitle}: "${exampleQ}"\n\n(Agentti odottaa päätöstäsi: ${reason})`);
}
// Normaali keskustelu aktiivisena
else if (selectedAgents.has(agent)) {
if (agent === 'client') {
wrap.setAttribute('data-tooltip', `💬 ${agentTitle}: "Mietin parhaillani uusia vaatimuksia!\nPysykää kuulolla, kerron niistä Managerille."`);
} else if (agent === 'manager') {
wrap.setAttribute('data-tooltip', `💬 ${agentTitle}: "Käyn läpi asiakkaan toiveita.\nDelegoin uudet taskit pian Koodarille ja QA:lle!"`);
} else {
const targets = { coder: 'DevOpsin', data: 'Koodarin', qa: 'Data-Agentin', tester: 'QA:n', observer: 'Managerin' };
const targetName = targets[agent] || 'Koodarin';
wrap.setAttribute('data-tooltip', `💬 ${agentTitle}: "Hei, minäkin haluan osallistua!\nVoisin tehdä ${targetName} asiaan tällaisen toiminnallisuuden!"`);
}
}
});
}
// Tarkistetaan heti alussa
setTimeout(checkAgentConfusion, 100);
// -- SIMULAATIO --
window.simulationInterval = null;
window.toggleSimulation = function() {
const btn = document.getElementById('simu-btn');
if (window.simulationInterval) {
clearInterval(window.simulationInterval);
window.simulationInterval = null;
if (btn) btn.textContent = 'Käynnistä simulaatio';
checkAgentConfusion(); // Palautetaan normaalitilat
return;
}
if (btn) btn.textContent = 'Lopeta simulaatio';
const agentsList = Object.keys(agentPrompts);
window.simulationInterval = setInterval(() => {
// Ensin putsataan kaikkien state takaisin normaaliksi
document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('state-alert', 'state-question'));
document.querySelectorAll('.gallery-head').forEach(g => g.classList.remove('state-alert', 'state-question'));
checkAgentConfusion();
// Arvotaan reagointi (20% todennäköisyys ettei kukaan hälytä juuri nyt)
if (Math.random() < 0.2) return;
const randAgent = agentsList[Math.floor(Math.random() * agentsList.length)];
const wrap = document.getElementById('wrap-' + randAgent);
const gel = document.getElementById('gallery-' + randAgent);
const agentTitle = (agentPrompts[randAgent].name.split(' — ')[0] || "AGENTTI").toUpperCase();
if (wrap && gel) {
const isAlert = Math.random() > 0.5;
const cClass = isAlert ? 'state-alert' : 'state-question';
// Poistetaan "normaali" active, ettei tooltip jää alle, vaikkei tämä ehkä haittaisi
wrap.classList.remove('state-alert', 'state-question');
gel.classList.remove('state-alert', 'state-question');
wrap.classList.add(cClass);
gel.classList.add(cClass);
const simAlerts = {
client: `${agentTitle}: "🚨 Aikataulu on vaarassa!"\n(Simuloitu huoli: "Miksi luvattu ominaisuus ei ole vielä tuotannossa?")`,
manager: `${agentTitle}: "🚨 Tiimin kapasiteetti ylitetty!"\n(Simuloitu varoitus: "Tarvitaan lisäresursseja Koodari-Nodelle heti.")`,
coder: `${agentTitle}: "🚨 Kääntäjävirhe!"\n(Simuloitu ongelma: "Riippuvuuspuu hajosi viimeisimmän commitin jälkeen. Korjaan...")`,
data: `${agentTitle}: "🚨 Tietokannan lukko!"\n(Simuloitu poikkeama: "Taulussa 'users' on transaction deadlock. Terminoidaan kysely.")`,
qa: `${agentTitle}: "🚨 Regressio havaittu!"\n(Simuloitu löydös: "Testiautomaatio raportoi 3 rikkinäistä polkua. Palautetaan koodarille!")`,
tester: `${agentTitle}: "🚨 Node piiputtaa!"\n(Simuloitu hälytys: "WebAssembly-muistinkulutus 95%. Allokoidaan lisää resursseja.")`,
observer: `${agentTitle}: "🚨 Poikkeama protokollassa!"\n(Simuloitu valvojan ilmoitus: "Solmu palautti epäilyttävän vastauksen. Rajoitan oikeuksia.")`
};
const simQuestions = {
client: `[?] ${agentTitle}: "Saisimmeko tähän vielä yhden muutoksen?"\n(Simuloitu lisätoive: "Voisimmeko muuttaa napin värit hieman kirkkaammiksi?")`,
manager: `[?] ${agentTitle}: "Onko arkkitehtuuri jo valmis?"\n(Simuloitu kysely: "Laittakaa minulle päivitys rajapintojen tilanteesta.")`,
coder: `[?] ${agentTitle}: "Täsmennystä kaivataan..."\n(Simuloitu kysely: "Tehdäänkö tämä komponentti uudestaan vai hyödynnetäänkö vanhaa?")`,
data: `[?] ${agentTitle}: "Outo tietorakenne?"\n(Simuloitu utelu: "Miksi asiakkaan lähettämä JSON on formatoitu näin? Pyydän korjausta.")`,
qa: `[?] ${agentTitle}: "Puuttuvat testiolosuhteet?"\n(Simuloitu ihmettely: "Onko meillä valmista testidataa tälle skenaariolle?")`,
tester: `[?] ${agentTitle}: "Julkaisulupa?"\n(Simuloitu kysymys: "Docker-imaget ovat valmiina. Voidaanko painaa nappia?")`,
observer: `[?] ${agentTitle}: "Laatumetriikat uupuvat..."\n(Simuloitu huomio: "Mittaustulokset viiveestä eivät ole saapuneet vielä lokiin.")`
};
let textRaw = "";
let termColor = "";
if (isAlert) {
const txt = simAlerts[randAgent] || `${agentTitle}: "🚨 Hälytys verkossa!"`;
wrap.setAttribute('data-tooltip', txt);
textRaw = txt.replace(/\n\(/g, ' - ').replace(/\)/g, '');
termColor = '#ff4444';
} else {
const txt = simQuestions[randAgent] || `[?] ${agentTitle}: "Minulla olisi ehdotus..."`;
wrap.setAttribute('data-tooltip', txt);
textRaw = txt.replace(/\n\(/g, ' - ').replace(/\)/g, '');
termColor = '#d29922';
}
// Tulostetaan tapahtuma terminaaliin
if (typeof termLog === 'function') {
termLog(`<span style="color:${termColor}">[SIMULAATIO]</span> ${textRaw}`);
}
// Häly kestää tasan 5 sekuntia, sitten palautuu normaaliksi
setTimeout(() => {
if (window.simulationInterval) {
wrap.classList.remove(cClass);
gel.classList.remove(cClass);
checkAgentConfusion();
}
}, 5000);
}
}, 6000); // Tapahtuu 6 sekunnin välein
};
window.switchMainTab = function(tab) {
document.querySelectorAll('.main-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
document.getElementById('panel-' + tab).classList.add('active');
document.querySelector(`.main-tab[onclick*="${tab}"]`).classList.add('active');
window.location.hash = tab;
// Siivotaan streaming-kortit näkymistä tab-vaihdon yhteydessä
document.querySelectorAll('.streaming-card').forEach(el => el.remove());
// Päivitetään admin-sessio vastaamaan nykyistä välilehteä
if (window._uiSocket && window._uiSocket.readyState === 1) {
const viewTask = tab === 'codelab' ? 'codelab-viewer' : 'viewer';
window._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: viewTask,
}));
}
// Codelab: käynnistetään oma laskentasolmu automaattisesti
// Agents: käyttäjä käynnistää itse "Alusta laskentasolmu" -napista
if (tab === 'codelab') {
if (typeof ensureCoderNode === 'function') ensureCoderNode();
}
};
// URL-hash navigointi
const initHash = window.location.hash.replace('#', '');
const hashMap = { 'laskentaverkko': 'network', 'network': 'network', 'codelab': 'codelab', 'agents': 'agents', 'builder': 'builder', 'gallery': 'gallery', 'guide': 'guide' };
if (hashMap[initHash]) {
switchMainTab(hashMap[initHash]);
}
// Synkronoi coder-status kun WS on jo auki (suora #codelab navigointi)
setTimeout(() => {
if (uiSocket && uiSocket.readyState === 1) {
const coderEl = document.getElementById('coder-status');
if (coderEl && coderEl.textContent === 'Ei yhdistetty') {
coderEl.textContent = 'Connected';
coderEl.style.color = '#d29922';
}
}
}, 1000);
// Koodilaboratorion tila
const codeMetrics = { tasks: 0, tokens: 0, lastSpeed: 0 };
let coderJoined = false;
// Globaali WebGPU-tila — tunnistetaan kerran viewer-authissa, käytetään kaikkialla
let detectedWebGPU = false;
let detectedGpuInfo = null;
let wasmInitialized = false;
let coderSize = localStorage.getItem('kpn-coder-size') || '05b';
// Mallivalinnan radio-napit — asetetaan oikea valinta localStoragesta
const savedRadio = document.querySelector(`input[name="coder-size"][value="${coderSize}"]`);
if (savedRadio) savedRadio.checked = true;
if (coderSize === '3b') {
document.getElementById('coder-opt-05b')?.style && (document.getElementById('coder-opt-05b').style.borderColor = 'var(--border-color)');
document.getElementById('coder-opt-3b')?.style && (document.getElementById('coder-opt-3b').style.borderColor = 'var(--accent-color)');
}
document.querySelectorAll('input[name="coder-size"]').forEach(radio => {
radio.addEventListener('change', (e) => {
coderSize = e.target.value;
localStorage.setItem('kpn-coder-size', coderSize);
// Visuaalinen korostus
document.getElementById('coder-opt-05b').style.borderColor = coderSize === '05b' ? 'var(--accent-color)' : 'var(--border-color)';
document.getElementById('coder-opt-3b').style.borderColor = coderSize === '3b' ? 'var(--accent-color)' : 'var(--border-color)';
// Jos jo liittynyt, pitää liittyä uudelleen toisella mallilla
if (coderJoined) {
coderJoined = false;
document.getElementById('coder-status').textContent = 'Model changed — rejoin on next generate';
document.getElementById('coder-status').style.color = '#d29922';
}
});
});
const btn = document.getElementById('start-btn');
const logBox = document.getElementById('log-box');
const loadSlider = document.getElementById('gpu-load');
const loadDisplay = document.getElementById('load-display');
const statNodes = document.getElementById('stat-nodes');
const statVram = document.getElementById('stat-vram');
const statTasks = document.getElementById('stat-tasks');
const chatBox = document.getElementById('chat-box');
// Tehtävävalitsin
let selectedTask = 'tokenize';
document.querySelectorAll('.task-option').forEach(opt => {
opt.addEventListener('click', () => {
document.querySelectorAll('.task-option').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
selectedTask = opt.dataset.task;
});
});
let currentChatMsg = null;
// Reaaliaikaiset metriikat
const metrics = {
tasks: 0,
totalTokens: 0,
totalTimeMs: 0,
startTime: null,
};
function updateMetrics() {
document.getElementById('m-tasks').textContent = metrics.tasks;
document.getElementById('m-tokens').textContent = metrics.totalTokens.toLocaleString('fi-FI');
document.getElementById('m-avg-time').textContent = metrics.tasks > 0
? (metrics.totalTimeMs / metrics.tasks).toFixed(1) + 'ms'
: '-';
if (metrics.startTime) {
const sec = Math.floor((Date.now() - metrics.startTime) / 1000);
if (sec < 60) document.getElementById('m-uptime').textContent = sec + 's';
else if (sec < 3600) document.getElementById('m-uptime').textContent = Math.floor(sec/60) + 'min';
else document.getElementById('m-uptime').textContent = Math.floor(sec/3600) + 'h ' + (Math.floor(sec/60)%60) + 'min';
}
}
setInterval(updateMetrics, 1000);
// Laskentaverkko: status Connected (keltainen) ↔ Computing (vihreä)
let computingTimer = null;
function flashComputing() {
const el = document.getElementById('node-status');
if (!el || !window.wasm_active) return;
el.textContent = 'Computing';
el.style.color = 'var(--success-color)';
clearTimeout(computingTimer);
computingTimer = setTimeout(() => {
el.textContent = 'Connected';
el.style.color = '#d29922';
}, 3000);
}
// Ylikirjoitetaan console.log uppoamaan lokilaatikkoon
const originalLog = console.log;
let logQueue = [];
let logFlushPending = false;
function flushLogs() {
if (!logQueue.length) return;
const frag = document.createDocumentFragment();
for (const msg of logQueue) {
const p = document.createElement('p');
p.textContent = '> ' + msg;
frag.appendChild(p);
}
logBox.appendChild(frag);
while (logBox.children.length > 20) logBox.removeChild(logBox.firstChild);
logBox.scrollTop = logBox.scrollHeight;
logQueue = [];
logFlushPending = false;
}
console.log = function(...args) {
originalLog.apply(console, args);
let msg = args.join(' ');
if (msg.includes("wgpu") || msg.includes("vastaanotettu") || msg.includes("Tehtävä vastaanotettu")) return;
logQueue.push(msg);
if (!logFlushPending) {
logFlushPending = true;
requestAnimationFrame(flushLogs);
}
};
// UI Slider Listener -> Lähettää arvon suoraan WebAssemblyn ytimeen!
loadSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
loadDisplay.textContent = val + '%';
if (window.wasm_active) {
set_gpu_load(val);
}
// Tilapäivitys
const statusEl = document.getElementById('node-status');
if (val === 0) {
statusEl.textContent = 'Pysäytetty';
statusEl.style.color = '#f85149';
} else if (val <= 25) {
statusEl.textContent = 'Säästötila';
statusEl.style.color = '#d29922';
} else {
statusEl.textContent = 'Aktiivinen';
statusEl.style.color = 'var(--success-color)';
}
});
// Automaattisten tehtävien toggle
document.getElementById('auto-tasks-toggle')?.addEventListener('change', (e) => {
if (window.wasm_active) {
set_auto_tasks(e.target.checked);
}
});
// Käyttäjän oma tekstisyöte
const userInput = document.getElementById('user-text');
const sendBtn = document.getElementById('send-btn');
function sendUserText() {
const text = userInput.value.trim();
if (!text || !uiSocket || uiSocket.readyState !== 1) return;
const msg = JSON.stringify({
type: 'user_text',
text: text,
task_type: selectedTask,
});
uiSocket.send(msg);
userInput.value = '';
console.log(`Lähetetty: "${text}" (${selectedTask})`);
}
sendBtn?.addEventListener('click', sendUserText);
userInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendUserText();
});
// WebSocket-yhteys hubiin — automaattinen reconnect
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
let uiSocket = null;
let wsReconnectTimer = null;
function connectHub() {
if (uiSocket && (uiSocket.readyState === 0 || uiSocket.readyState === 1)) return;
uiSocket = new WebSocket(wsUrl);
window._uiSocket = uiSocket;
// Kytketään onmessage uudelleen (handler määritellään myöhemmin, asetetaan kun valmis)
setTimeout(() => {
if (window._wsMessageHandler) uiSocket.onmessage = window._wsMessageHandler;
}, 0);
uiSocket.onopen = async () => {
// Päivitetään agents-näkymän hub-status
const hubDot = document.getElementById('agent-hub-dot');
const hubLabel = document.getElementById('agent-hub-label');
const hubStatus = document.getElementById('agent-hub-status');
if (hubDot) hubDot.style.background = '#3fb950';
// Poistetaan reconnect-rivi
const reconnLine = document.getElementById('agent-terminal')?.querySelector('.term-reconnect');
if (reconnLine) reconnLine.remove();
if (hubLabel) { hubLabel.textContent = 'Yhdistetty'; hubLabel.style.color = '#3fb950'; }
if (hubStatus) hubStatus.title = 'Yhdistetty Kipinä Hubiin — tehtävien jakelu ja solmujen koordinointi aktiivinen';
// Päivitetään molemmat statukset
const el = document.getElementById('node-status');
el.textContent = 'Connected';
el.style.color = '#d29922';
const coderEl = document.getElementById('coder-status');
if (coderEl && !coderJoined) {
coderEl.textContent = 'Connected';
coderEl.style.color = '#d29922';
}
// Tunnistetaan WebGPU kunnolla (adapter + info) — tallennetaan globaalisti
if (navigator.gpu) {
try {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
detectedWebGPU = true;
const info = adapter.info || {};
const maxBuf = Number(adapter.limits.maxBufferSize || 0);
detectedGpuInfo = {
vendor: info.vendor || '',
description: info.description || '',
architecture: info.architecture || '',
device: info.device || '',
estimated_vram_gb: maxBuf > 0 ? Math.round(maxBuf / 1024 / 1024 / 1024 * 4) : 0,
max_buffer_size: maxBuf,
max_compute_workgroups: adapter.limits.maxComputeWorkgroupsPerDimension || 0,
};
}
} catch(e) {}
}
const hasGPU = detectedWebGPU;
const gpuInfo = detectedGpuInfo;
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',
has_webgpu: hasGPU,
gpu: gpuInfo,
}));
};
uiSocket.onclose = () => {
const hubDot = document.getElementById('agent-hub-dot');
const hubLabel = document.getElementById('agent-hub-label');
const hubStatus2 = document.getElementById('agent-hub-status');
if (hubDot) hubDot.style.background = '#f85149';
if (hubLabel) { hubLabel.textContent = 'Yhteys katkennut'; hubLabel.style.color = '#f85149'; }
if (hubStatus2) hubStatus2.title = 'WebSocket-yhteys hubiin katkesi — tarkista verkkoyhteytesi tai hubin tila. Lataa sivu uudelleen yhdistääksesi.';
const el = document.getElementById('node-status');
el.textContent = 'Disconnected';
el.style.color = '#f85149';
const coderEl = document.getElementById('coder-status');
if (coderEl) {
coderEl.textContent = 'Disconnected';
coderEl.style.color = '#f85149';
}
// Automaattinen reconnect 3s kuluttua
if (!wsReconnectTimer) {
// Päivitetään samaa riviä eikä floodata uusia
let reconnLine = termPanel?.querySelector('.term-reconnect');
let reconnCount = 0;
if (!reconnLine) {
reconnLine = document.createElement('div');
reconnLine.className = 'terminal-line term-reconnect';
termPanel?.appendChild(reconnLine);
} else {
reconnCount = parseInt(reconnLine.dataset.count || '0');
}
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
reconnCount++;
reconnLine.dataset.count = reconnCount;
reconnLine.innerHTML = ` <span style="color:#d29922">↻ Yhdistetään uudelleen...${reconnCount > 1 ? ' (' + reconnCount + ')' : ''}</span>`;
termPanel.scrollTop = termPanel.scrollHeight;
connectHub();
}, 3000);
}
};
} // connectHub()
connectHub();
// Terminaalin komentorivi
const termInput = document.getElementById('term-input');
const termPanel = document.getElementById('agent-terminal');
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.removeChild(termPanel.firstChild);
termPanel.scrollTop = termPanel.scrollHeight;
}
// Aktiiviset streaming-rivit task_id:n mukaan
const activeStreams = {};
// Lähettää promptin mallille ja palauttaa vastauksen (tai null virhetilanteessa)
async function kpnRun(model, prompt, silent, maxTokens) {
const taskId = crypto.randomUUID();
// Yksittäinen status-rivi jota päivitetään läpi pyynnön elinkaaren
const statusDiv = document.createElement('div');
statusDiv.className = 'terminal-line';
statusDiv.id = 'status-' + taskId;
statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:#58a6ff">${model}</span> käsittelee... <span style="color:#d29922">(selain voi hidastua)</span></span>`;
termPanel.appendChild(statusDiv);
termPanel.scrollTop = termPanel.scrollHeight;
// Yield jotta status-rivi ehditään piirtää ennen mahdollista blokkia
await new Promise(r => setTimeout(r, 50));
try {
const agent = Object.values(agentPrompts).find(a => a.model === model);
const parts = [];
if (sharedPrompt) parts.push(sharedPrompt);
if (agent && agent.prompt) parts.push(agent.prompt);
// Ladataan agentin docs-referenssi (esim. OpenTofu cheatsheet)
if (agent && agent.docs && !agent._docsCache) {
try {
const r = await fetch(agent.docs);
if (r.ok) agent._docsCache = await r.text();
} catch(e) { /* docs ei saatavilla */ }
}
if (agent && agent._docsCache) parts.push('Reference:\n' + agent._docsCache);
parts.push(prompt);
const fullPrompt = parts.join('\n\n');
// Luodaan streaming-rivi terminaaliin
if (!silent) {
const streamDiv = document.createElement('div');
streamDiv.className = 'terminal-line';
streamDiv.style.color = '#c9d1d9';
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;
}
const res = await fetch('/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: fullPrompt, task_id: taskId, ...(maxTokens ? { max_tokens: maxTokens } : {}) }),
});
if (!res.ok) {
const errText = await res.text().catch(() => res.statusText);
statusDiv.innerHTML = ` <span style="color:#f85149">✗ ${esc(errText)}</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' : '';
const inspectId = 'inspect-' + taskId;
// Prompt-inspektor: tallennetaan promptin osat
const systemPrompt = 'You are a coding assistant. Respond with ONLY code. No explanations, no markdown, no comments unless asked.';
const agentPromptText = agent?.prompt || '';
const inputTokensEst = Math.round(fullPrompt.length / 3.5);
statusDiv.innerHTML = ` <span style="color:#3fb950">✓</span> <span style="color:#58a6ff">${esc(data.model || model)}</span> <span style="color:#8b949e">${tokGen} tok · ${durS} · ${tokS}</span>`
+ ` <span style="color:#30363d;cursor:pointer;font-size:11px" onclick="document.getElementById('${inspectId}').style.display=document.getElementById('${inspectId}').style.display==='none'?'block':'none'" title="Prompt Inspector">[&gt;]</span>`
+ `<div id="${inspectId}" style="display:none;margin:6px 0 4px 16px;padding:8px 12px;background:#0d1117;border:1px solid #30363d;border-radius:4px;font-size:12px;line-height:1.6">`
+ `<div style="color:#8b949e;margin-bottom:6px">Prompt Inspector · <span style="color:#58a6ff">~${inputTokensEst} tok in</span> → <span style="color:#3fb950">${tokGen} tok out</span></div>`
+ `<div style="margin-bottom:4px"><span style="color:#f85149">system:</span> <span style="color:#8b949e">${esc(systemPrompt)}</span></div>`
+ (sharedPrompt ? `<div style="margin-bottom:4px"><span style="color:#d2a8ff">shared:</span> <span style="color:#8b949e">${esc(sharedPrompt).substring(0, 150)}${sharedPrompt.length > 150 ? '...' : ''}</span></div>` : '')
+ (agentPromptText ? `<div style="margin-bottom:4px"><span style="color:#d29922">agent:</span> <span style="color:#8b949e">${esc(agentPromptText)}</span></div>` : '')
+ `<div style="margin-bottom:4px"><span style="color:#3fb950">user:</span> <pre style="margin:2px 0 0 0;padding:6px;background:#161b22;border-radius:3px;white-space:pre-wrap;color:#c9d1d9;font:inherit;max-height:150px;overflow-y:auto">${esc(prompt)}</pre></div>`
+ `<div><span style="color:#58a6ff">prefill:</span> <span style="color:#8b949e">\`\`\`</span></div>`
+ `</div>`;
if (!silent) {
// Kompakti yksirivinen esikatselu — klikkaa/hover laajentaa
const firstLine = response.split('\n').find(l => l.trim()) || response;
const lineCount = response.split('\n').filter(l => l.trim()).length;
const preview = esc(firstLine.trim());
const fullHighlighted = highlightCode(response).replace(/\n/g, '\n ');
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'" title="Klikkaa nähdäksesi koko koodi">`
+ `<span style="color:#8b949e">▶</span> ${preview} <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 #30363d;padding-left:10px">${fullHighlighted}</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];
}
}
}
// Pipeline-vaiheiden seuranta ja visualisointi
const pipelineSteps = [];
function pipelineStep(agent, label, status, input, output) {
const step = { agent, label, status, input: input || '', output: output || '' };
// Päivitetään olemassaoleva tai lisätään uusi
const existing = pipelineSteps.find(s => s.label === label && s.status !== 'done');
if (existing && status !== 'done') {
Object.assign(existing, step);
} else if (status === 'done' && existing) {
existing.status = 'done';
existing.output = output || existing.output;
} else {
pipelineSteps.push(step);
}
renderPipelineSteps();
// Päivitetään agentin avatar tooltip + vilahdus
const avatarMap = { manager: 'avatar-kpn', coder: 'avatar-coder', tester: 'avatar-tester', qa: 'avatar-qa', data: 'avatar-data', tofuist: 'avatar-tofuist' };
const avatarId = avatarMap[agent];
if (avatarId) {
const el = document.getElementById(avatarId);
if (el) {
const truncOut = (output || '').substring(0, 200).replace(/\n/g, ' ');
el.title = `${label}\n${status === 'active' ? '⏳ Käsittelee...' : '✓ Valmis'}\n\nInput: ${(input || '').substring(0, 100)}...\nOutput: ${truncOut}...`;
// Avatar-aktivointi: syttyy vuoron alussa, sammuu lopussa
if (status === 'active') {
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
el.classList.add('active');
document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('active'));
const gw = document.getElementById('wrap-' + agent);
if (gw) gw.classList.add('active');
} else if (status === 'done') {
el.classList.remove('active');
const gw = document.getElementById('wrap-' + agent);
if (gw) gw.classList.remove('active');
}
}
}
}
function renderPipelineSteps() {
const container = document.getElementById('pipeline-steps');
if (!container) return;
if (pipelineSteps.length === 0) { container.style.display = 'none'; return; }
container.style.display = 'flex';
container.innerHTML = pipelineSteps.map((s, i) => {
const colors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff', tofuist: '#e3a336' };
const color = colors[s.agent] || '#8b949e';
const icon = s.status === 'done' ? '✓' : s.status === 'active' ? '◷' : '◯';
const iconColor = s.status === 'done' ? '#3fb950' : s.status === 'active' ? '#d29922' : '#8b949e';
const arrow = i < pipelineSteps.length - 1 ? ' <span style="color:#30363d">→</span> ' : '';
const stepDescs = { 'Suunnittelu': 'Pilkkoo tiedostoiksi', 'Review': 'Arvioi koodin laadun', 'Testit': 'Kirjoittaa testit', 'Dockerfile': 'Docker-image', 'Compose': 'Palvelumääritys', 'README': 'Dokumentaatio', 'Validointi': 'Tarkistaa yhteensopivuuden', 'Korjaukset': 'Korjaa löydetyt bugit' };
const desc = stepDescs[s.label] || s.label;
return `<span onclick="openPipelineStepModal(${i})" title="${desc}" style="cursor:pointer;padding:2px 4px;border-radius:3px;transition:background 0.2s" onmouseenter="this.style.background='#21262d'" onmouseleave="this.style.background='transparent'"><span style="color:${iconColor}">${icon}</span> <span style="color:${color}">${esc(s.label)}</span></span>${arrow}`;
}).join('');
}
window.openPipelineStepModal = function(idx) {
const s = pipelineSteps[idx];
if (!s) return;
// Näytetään modal promptilla (input) ja tuloksella (output)
const combined = (s.input ? s.input : '') + (s.output ? '\n\n--- Tulos ---\n' + s.output : '');
openPromptModal(s.agent, s.label, combined);
};
function pipelineClear() {
pipelineSteps.length = 0;
const container = document.getElementById('pipeline-steps');
if (container) container.style.display = 'none';
}
// Projektikortti: tiedostovälilehdet + kopioi + lataa ZIP
// Globaali storage projektikorttien tiedostoille (välttää JSON data-attribuuttien ongelmat)
const projectFiles = {};
function renderProjectCard(files, projectName, reportUrl) {
const fileEntries = Object.entries(files);
if (fileEntries.length === 0) return;
const cardId = 'proj-' + Date.now();
projectFiles[cardId] = files;
const tabsHtml = fileEntries.map(([name], i) =>
`<span class="proj-tab" data-card="${cardId}" data-idx="${i}" style="padding:4px 10px;cursor:pointer;border-radius:4px 4px 0 0;font-size:12px;${i === 0 ? 'background:#161b22;color:#58a6ff;border:1px solid #30363d;border-bottom:none' : 'color:#8b949e'}" onclick="switchProjectTab('${cardId}',${i})">${esc(name)}</span>`
).join('');
const panelsHtml = fileEntries.map(([name, code], i) =>
`<div class="proj-panel" data-card="${cardId}" data-idx="${i}" style="${i > 0 ? 'display:none' : ''}">
<div style="display:flex;justify-content:flex-end;padding:4px 8px;background:#0d1117;border-bottom:1px solid #21262d">
<button onclick="copyFileContent('${cardId}',${i})" style="background:none;border:1px solid #30363d;color:#8b949e;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Kopioi ${esc(name)} leikepöydälle">Kopioi</button>
</div>
<pre style="margin:0;padding:10px;font-size:12px;line-height:1.5;overflow-x:auto;white-space:pre-wrap">${highlightCode(code)}</pre>
</div>`
).join('');
const allText = fileEntries.map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n');
const cardHtml = `
<div id="${cardId}" style="margin:8px 0;border:1px solid #30363d;border-radius:6px;background:#161b22;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d">
<span style="color:#a371f7;font-weight:600;font-size:13px">${esc(projectName || 'Projekti')} <span style="color:#8b949e;font-weight:normal">(${fileEntries.length} tiedostoa)</span></span>
<span style="display:flex;gap:6px">
<button onclick="copyAllFiles('${cardId}')" style="background:none;border:1px solid #30363d;color:#8b949e;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Kopioi kaikki tiedostot leikepöydälle">Kopioi kaikki</button>
<button onclick="downloadZip('${cardId}')" style="background:none;border:1px solid #30363d;color:#58a6ff;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Lataa projekti ZIP-tiedostona">Lataa ZIP</button>
${reportUrl ? `<a href="${reportUrl}" target="_blank" style="background:none;border:1px solid #a371f7;color:#a371f7;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer;text-decoration:none">📄 Raportti</a>` : ''}
</span>
</div>
<div style="display:flex;gap:2px;padding:6px 8px 0;background:#0d1117">${tabsHtml}</div>
<div style="background:#161b22">${panelsHtml}</div>
</div>`;
const div = document.createElement('div');
div.innerHTML = cardHtml;
termPanel.appendChild(div.firstElementChild);
termPanel.scrollTop = termPanel.scrollHeight;
}
// Globaalit funktiot projektikortin interaktioille
window.switchProjectTab = function(cardId, idx) {
document.querySelectorAll(`.proj-tab[data-card="${cardId}"]`).forEach((tab, i) => {
tab.style.background = i === idx ? '#161b22' : 'transparent';
tab.style.color = i === idx ? '#58a6ff' : '#8b949e';
tab.style.border = i === idx ? '1px solid #30363d' : 'none';
tab.style.borderBottom = i === idx ? 'none' : '';
});
document.querySelectorAll(`.proj-panel[data-card="${cardId}"]`).forEach((panel, i) => {
panel.style.display = i === idx ? '' : 'none';
});
};
window.copyFileContent = function(cardId, idx) {
const card = document.getElementById(cardId);
if (!card) return;
const files = projectFiles[cardId];
const entries = Object.entries(files);
if (entries[idx]) {
navigator.clipboard.writeText(entries[idx][1]);
// Visuaalinen palaute
const btn = card.querySelectorAll(`.proj-panel[data-idx="${idx}"] button`)[0];
if (btn) { const orig = btn.textContent; btn.textContent = '✓ Kopioitu'; setTimeout(() => btn.textContent = orig, 1500); }
}
};
window.copyAllFiles = function(cardId) {
const card = document.getElementById(cardId);
if (!card) return;
const files = projectFiles[cardId];
const text = Object.entries(files).map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n');
navigator.clipboard.writeText(text);
const btn = card.querySelector('[onclick*="copyAllFiles"]');
if (btn) { const orig = btn.textContent; btn.textContent = '✓ Kopioitu'; setTimeout(() => btn.textContent = orig, 1500); }
};
window.downloadZip = async function(cardId) {
const card = document.getElementById(cardId);
if (!card) return;
const files = projectFiles[cardId];
if (!files || Object.keys(files).length === 0) {
alert('Tiedostot eivät ole enää muistissa — aja pipeline uudelleen.');
return;
}
// CRC-32 laskenta ZIP-tiedostoille
function crc32(bytes) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc ^= bytes[i];
for (let j = 0; j < 8; j++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
}
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
const entries = Object.entries(files);
const parts = [];
const centralDir = [];
let offset = 0;
for (const [name, content] of entries) {
const nameBytes = new TextEncoder().encode(name);
const contentBytes = new TextEncoder().encode(content || '');
const crc = crc32(contentBytes);
// Local file header
const header = new Uint8Array(30 + nameBytes.length);
const view = new DataView(header.buffer);
view.setUint32(0, 0x04034b50, true); // Signature
view.setUint16(4, 20, true); // Version needed
view.setUint16(8, 0, true); // Method: store
view.setUint32(14, crc, true); // CRC-32
view.setUint32(18, contentBytes.length, true);
view.setUint32(22, contentBytes.length, true);
view.setUint16(26, nameBytes.length, true);
header.set(nameBytes, 30);
// Central directory entry
const cdEntry = new Uint8Array(46 + nameBytes.length);
const cdView = new DataView(cdEntry.buffer);
cdView.setUint32(0, 0x02014b50, true);
cdView.setUint16(4, 20, true);
cdView.setUint16(6, 20, true);
cdView.setUint32(16, crc, true); // CRC-32
cdView.setUint32(20, contentBytes.length, true);
cdView.setUint32(24, contentBytes.length, true);
cdView.setUint16(28, nameBytes.length, true);
cdView.setUint32(42, offset, true);
cdEntry.set(nameBytes, 46);
parts.push(header, contentBytes);
centralDir.push(cdEntry);
offset += header.length + contentBytes.length;
}
const cdOffset = offset;
let cdSize = 0;
for (const cd of centralDir) { parts.push(cd); cdSize += cd.length; }
// End of central directory
const eocd = new Uint8Array(22);
const eocdView = new DataView(eocd.buffer);
eocdView.setUint32(0, 0x06054b50, true);
eocdView.setUint16(8, entries.length, true);
eocdView.setUint16(10, entries.length, true);
eocdView.setUint32(12, cdSize, true);
eocdView.setUint32(16, cdOffset, true);
parts.push(eocd);
const blob = new Blob(parts, { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'project.zip';
a.click();
URL.revokeObjectURL(url);
};
// Pipeline: manageri → koodari (per tiedosto) → testaaja → korjausluuppi
async function kpnPipeline(task) {
pipelineClear();
termLog(`<span style="color:#a371f7;font-weight:bold">━━━ Pipeline käynnistyy ━━━</span>`);
// Vaihe 1: Manageri pilkkoo projektin tiedostoiksi
const managerPrompt = `List the source files needed for this project. One file per line, format:
filename.py: one-line description
CONSTRAINTS — the coder can only generate ~400 tokens per file:
- Max 3 files (keep it minimal)
- Each file must be SHORT: one clear responsibility, no boilerplate
- Only .py, .html and pyproject.toml files
- If the project has a UI, include one index.html served by FastAPI at /
- No directories, no paths, just filenames
- List dependencies first, then main app
EXAMPLE for "FastAPI todo app with SQLite":
pyproject.toml: project metadata and dependencies
models.py: SQLAlchemy models and database setup
main.py: FastAPI app with CRUD endpoints and serves index.html at /
index.html: simple HTML UI with forms and fetch() to call the API
Project: ${task}`;
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`);
pipelineStep('manager', 'Suunnittelu', 'active', managerPrompt);
const plan = await kpnRun(agentPrompts.manager.model, managerPrompt, false, 200);
if (!plan) { termLog(' ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; }
pipelineStep('manager', 'Suunnittelu', 'done', managerPrompt, plan);
// Parsitaan tiedostolista: "filename.py: description" TAI pelkkä "filename.py"
const fileList = plan.split('\n')
.map(line => line.trim().replace(/^[\d\.\-\*\s]+/, '').replace(/\*+/g, '').replace(/`/g, ''))
.map(line => {
if (line.includes(':')) {
const [name, ...desc] = line.split(':');
return { name: name.trim(), desc: desc.join(':').trim() };
}
// Pelkkä tiedostonimi ilman kuvausta
return { name: line.trim(), desc: '' };
})
.filter(f => {
const n = f.name;
return n.length > 0 && n.length < 40 && !n.includes('/') && !n.includes(' ')
&& /\.\w{1,5}$/.test(n);
});
if (fileList.length === 0) {
// Fallback: manageri ei tuottanut tiedostolistaa, käytetään koko vastausta ohjeena
termLog(' <span style="color:#8b949e">Ei tiedostojakoa — generoidaan yhtenä kokonaisuutena</span>');
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2] Koodari</span> — toteutus`);
const code = await kpnRun(agentPrompts.coder.model, `Project: ${task}\nFiles: ${plan}\n\nWrite all the code for this project. Use the exact libraries mentioned in the project description. Use pyproject.toml for dependencies (not requirements.txt).`);
if (code) {
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`);
}
return;
}
termLog(` <span style="color:#8b949e">${fileList.length} tiedostoa: ${fileList.map(f => f.name).join(', ')}</span>`);
// Vaihe 2: Koodari generoi tiedosto kerrallaan, konteksti ketjutetaan
const generatedFiles = {};
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${i + 2}] Koodari</span> — ${esc(file.name)}`);
pipelineStep('coder', file.name, 'active', '');
// Rakennetaan konteksti: aiemmin generoidut tiedostot
let context = '';
const prevFiles = Object.entries(generatedFiles);
if (prevFiles.length > 0) {
context = 'Already written files:\n' + prevFiles.map(([name, code]) =>
`--- ${name} ---\n${code}`
).join('\n\n') + '\n\n';
}
// Erityisohjeet pyproject.toml / requirements.txt -tiedostoille
let extraInstructions = '';
if (file.name === 'pyproject.toml') {
extraInstructions = `\nUse this exact format:
[project]
name = "projectname"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["fastapi", "uvicorn", "sqlalchemy", "httpx", "pytest"]
[project.scripts]
start = "uvicorn main:app --reload"
IMPORTANT: Only list pip-installable packages. NEVER include Python stdlib modules like sqlite3, os, sys, json, typing, collections, etc.`;
} else if (file.name === 'requirements.txt') {
extraInstructions = '\nList one dependency per line. No version pins unless necessary.';
}
const coderExample = file.name.includes('main') || file.name.includes('app')
? `\nEXAMPLE output for a main.py (CRUD + HTML UI):
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from models import get_db, Base, engine, User
Base.metadata.create_all(engine)
app = FastAPI()
@app.get("/")
def index():
return FileResponse("index.html")
@app.get("/api/users")
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
@app.post("/api/users")
def create_user(name: str, db: Session = Depends(get_db)):
user = User(name=name)
db.add(user)
db.commit()
return {"id": user.id, "name": user.name}
@app.put("/api/users/{user_id}")
def update_user(user_id: int, name: str, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
if not user: raise HTTPException(404)
user.name = name
db.commit()
return {"id": user.id, "name": user.name}
@app.delete("/api/users/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
if not user: raise HTTPException(404)
db.delete(user)
db.commit()
return {"ok": True}`
: file.name.includes('index.html')
? `\nEXAMPLE output for index.html (simple CRUD UI):
<!DOCTYPE html>
<html><head><title>App</title></head>
<body>
<h1>Users</h1>
<input id="name" placeholder="Name"><button onclick="add()">Add</button>
<ul id="list"></ul>
<script>
async function load() {
const r = await fetch('/api/users');
const users = await r.json();
document.getElementById('list').innerHTML = users.map(u =>
'<li>' + u.name + ' <button onclick="del('+u.id+')">x</button></li>'
).join('');
}
async function add() {
const name = document.getElementById('name').value;
await fetch('/api/users?name='+name, {method:'POST'});
document.getElementById('name').value = '';
load();
}
async function del(id) {
await fetch('/api/users/'+id, {method:'DELETE'});
load();
}
load();
<` + `/script></body></html>`
: file.name.includes('model')
? `\nEXAMPLE output for a models.py:
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_engine("sqlite:///app.db")
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase): pass
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
Base.metadata.create_all(engine)
def get_db():
db = SessionLocal()
try: yield db
finally: db.close()`
: '';
const coderPrompt = `${context}Project: ${task}
Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions}${coderExample}
IMPORTANT: Keep the code SHORT. Max ~60 lines. No comments, no docstrings. Write minimal, working code. Output ONLY code. Serve index.html at / using FileResponse. Use /api/ prefix for JSON endpoints.`;
const code = await kpnRun(agentPrompts.coder.model, coderPrompt);
if (!code) {
termLog(` ✗ Pipeline keskeytyi (${file.name})`, '#f85149');
return;
}
generatedFiles[file.name] = code;
pipelineStep('coder', file.name, 'done', coderPrompt, code);
}
// Vaihe 3: Testaaja arvioi koko projektin
const allCode = Object.entries(generatedFiles)
.map(([name, code]) => `--- ${name} ---\n${code}`)
.join('\n\n');
// Staattinen analyysi ennen LLM-arviointia
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 2}] Testaaja</span> — staattinen analyysi + arviointi`);
pipelineStep('tester', 'Review', 'active', `${Object.keys(generatedFiles).length} tiedostoa`);
// Yksinkertainen staattinen tarkistus selaimessa
const staticIssues = [];
for (const [name, code] of Object.entries(generatedFiles)) {
if (!name.endsWith('.py')) continue;
const lines = (code || '').split('\n');
// Tarkista importit
const imports = lines.filter(l => l.match(/^(from|import)\s/));
const usedNames = code.replace(/^(from|import)\s.*/gm, '');
for (const imp of imports) {
const match = imp.match(/import\s+(\w+)|from\s+\S+\s+import\s+(.+)/);
if (match) {
const names = (match[1] || match[2]).split(',').map(n => n.trim().split(' as ').pop().trim());
for (const n of names) {
if (n && !usedNames.includes(n) && n !== '*') {
staticIssues.push(`${name}: käyttämätön import '${n}'`);
}
}
}
}
// Tarkista puuttuvat importit
const importText = imports.join(' ');
const needsImport = [
['FastAPI', 'FastAPI'], ['Session', 'Session'], ['Depends', 'Depends'],
['HTTPException', 'HTTPException'], ['TestClient', 'TestClient'],
['Boolean', 'Boolean'], ['Text', 'Text'], ['Float', 'Float'], ['DateTime', 'DateTime'],
['Column', 'Column'], ['create_engine', 'create_engine'],
['DeclarativeBase', 'DeclarativeBase'], ['sessionmaker', 'sessionmaker'],
];
for (const [symbol, name_] of needsImport) {
// Tarkista käytetäänkö symbolia koodissa (ei import-riveillä)
const codeWithoutImports = lines.filter(l => !l.match(/^(from|import)\s/)).join('\n');
if (codeWithoutImports.includes(symbol) && !importText.includes(symbol)) {
staticIssues.push(`${name}: käyttää '${symbol}' mutta ei importtaa sitä`);
}
}
// Tarkista tyhjät funktiot
const emptyFuncs = code.match(/def \w+\([^)]*\):\s*\n\s*(pass|\.\.\.)/g);
if (emptyFuncs) staticIssues.push(`${name}: ${emptyFuncs.length} tyhjää funktiota`);
// Tarkista one-liner koodi (kaikki yhdellä rivillä)
if (lines.length <= 2 && code.length > 200) {
staticIssues.push(`${name}: koodi on yhdellä rivillä (${code.length} merkkiä) — generoi uudelleen`);
}
// Tarkista tiedostojen väliset importit (from db import get_db → onko get_db db.py:ssä?)
for (const imp of imports) {
const crossMatch = imp.match(/from\s+(\w+)\s+import\s+(.+)/);
if (crossMatch) {
const modName = crossMatch[1];
const importedNames = crossMatch[2].split(',').map(n => n.trim().split(' as ')[0].trim());
const targetFile = modName + '.py';
const targetCode = generatedFiles[targetFile];
if (targetCode) {
for (const sym of importedNames) {
// Tarkista onko symboli määritelty kohdetiedostossa (def, class, muuttuja)
const defined = targetCode.includes(`def ${sym}`) || targetCode.includes(`class ${sym}`) || targetCode.match(new RegExp(`^${sym}\\s*=`, 'm'));
if (!defined) {
staticIssues.push(`${name}: importtaa '${sym}' tiedostosta ${targetFile}, mutta sitä ei ole määritelty siellä`);
}
}
}
}
}
}
if (staticIssues.length > 0) {
termLog(` <span style="color:#d29922">Staattinen analyysi (${staticIssues.length} huomautusta):</span>`);
for (const issue of staticIssues) {
termLog(` <span style="color:#d29922">⚠</span> ${esc(issue)}`);
}
} else {
termLog(' <span style="color:#3fb950">Staattinen analyysi: ei huomautuksia</span>');
}
const reviewPrompt = `Review this project code. Check EVERY item and report result:
1. Imports: ✓/✗ — are all imports valid and available?
2. Database: ✓/✗ — is the DB setup correct (engine, session, models)?
3. Endpoints: ✓/✗ — do all routes have correct parameters and return types?
4. Error handling: ✓/✗ — are edge cases handled (404, validation)?
5. Security: ✓/✗ — any SQL injection, missing auth, or data exposure?
EXAMPLE output:
1. Imports: ✓ — all imports are valid
2. Database: ✗ — missing Base.metadata.create_all(engine) call
3. Endpoints: ✓ — GET/POST/DELETE routes are correct
4. Error handling: ✗ — no 404 when todo not found
5. Security: ✓ — using ORM, no raw SQL
${allCode}`;
const review = await kpnRun(agentPrompts.tester.model, reviewPrompt, false, 300);
pipelineStep('tester', 'Review', 'done', `${Object.keys(generatedFiles).length} tiedostoa`, review);
// Vaihe 4: Korjausluuppi — jos testaaja löysi ongelmia
const hasIssues = review && (review.includes('✗') || staticIssues.length > 0);
if (hasIssues) {
termLog(`\n<span style="color:#d29922;font-weight:bold">[${fileList.length + 3}] Koodari</span> — korjaukset`);
pipelineStep('coder', 'Korjaukset', 'active', review);
const fixPrompt = `Fix the issues found in the review.
Review feedback: ${review}
Current code:
${allCode}
Write the corrected code.`;
const fixedCode = await kpnRun(agentPrompts.coder.model, fixPrompt);
pipelineStep('coder', 'Korjaukset', 'done', review, fixedCode);
if (fixedCode) {
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 4}] Testaaja</span> — uudelleenarviointi`);
pipelineStep('tester', 'Re-review', 'active', fixedCode);
const reReview = await kpnRun(agentPrompts.tester.model, `Review the corrected code briefly:\n${fixedCode}`, false, 128);
pipelineStep('tester', 'Re-review', 'done', fixedCode, reReview);
}
}
// Vaihe 5: QA kirjoittaa testit
const step5 = fileList.length + (review && !review.toLowerCase().includes('lgtm') ? 5 : 3);
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step5}] QA</span> — testit`);
pipelineStep('qa', 'Testit', 'active', 'Kirjoitetaan testejä');
const qaPrompt = `Write test_app.py using pytest and FastAPI TestClient. Max 3 tests. Output ONLY code.
EXAMPLE:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create():
r = client.post("/users", params={"name": "Test"})
assert r.status_code == 200
def test_list():
r = client.get("/users")
assert r.status_code == 200
assert isinstance(r.json(), list)
PROJECT CODE:
${Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n')}`;
const tests = await kpnRun(agentPrompts.qa.model, qaPrompt, false, 512);
if (tests) generatedFiles['test_app.py'] = tests;
pipelineStep('qa', 'Testit', 'done', 'test_app.py', tests);
// Vaihe 6: DevOps — Dockerfile
const step6 = step5 + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step6}] DevOps</span> — Dockerfile`);
pipelineStep('tester', 'Dockerfile', 'active', 'Dockerfile');
const mainFile = Object.keys(generatedFiles).find(f => f.includes('main') || f.includes('app')) || Object.keys(generatedFiles)[0];
const hasPyproject = 'pyproject.toml' in generatedFiles;
const hasRequirements = 'requirements.txt' in generatedFiles;
const codeFiles = Object.keys(generatedFiles).filter(f => f.endsWith('.py') || f.endsWith('.html'));
// Dockerfile-templatti: ei anneta mallin keksiä omaa
let depLines;
if (hasPyproject) {
depLines = 'COPY pyproject.toml .\nRUN uv sync --no-dev';
} else if (hasRequirements) {
depLines = 'COPY requirements.txt .\nRUN uv pip install --system -r requirements.txt';
} else {
depLines = 'RUN uv pip install --system fastapi uvicorn';
}
const dockerfileContent = `FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
${depLines}
COPY ${codeFiles.join(' ')} ./
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]`;
// Generoidaan Dockerfile suoraan templatesta, ei mallilla
generatedFiles['Dockerfile'] = dockerfileContent;
termLog(` <span style="color:#3fb950">✓</span> Dockerfile generoitu (template)`);
pipelineStep('tester', 'Dockerfile', 'done', dockerfileContent, dockerfileContent);
// Vaihe 7: DevOps — docker-compose.yml
const step7 = step6 + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step7}] DevOps</span> — docker-compose.yml`);
pipelineStep('tester', 'Compose', 'active', 'docker-compose.yml');
// docker-compose.yml templatesta (ei LLM:llä — vältetään version/postgres ongelmat)
const composeContent = `services:
app:
build: .
ports:
- "8000:8000"
volumes:
- app-data:/app/data
restart: unless-stopped
volumes:
app-data:`;
generatedFiles['docker-compose.yml'] = composeContent;
termLog(` <span style="color:#3fb950">✓</span> docker-compose.yml generoitu (template)`);
pipelineStep('tester', 'Compose', 'done', composeContent, composeContent);
// Vaihe 8: DevOps — README
const step8 = step7 + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${step8}] DevOps</span> — README`);
pipelineStep('tester', 'README', 'active', 'README.md');
const readmePrompt = `Write a minimal README.md. Include ONLY:
1. One-line description
2. Quick start: docker compose up → open http://localhost:8000
3. Development: uv sync && uv run uvicorn main:app --reload → http://localhost:8000
4. API endpoints (if applicable)
5. Testing: uv run pytest
Max 20 lines.
Files: ${Object.keys(generatedFiles).join(', ')}`;
const readme = await kpnRun(agentPrompts.tester.model, readmePrompt, false, 256);
if (readme) generatedFiles['README.md'] = readme;
pipelineStep('tester', 'README', 'done', 'README.md', readme);
// Validointivaihe: QA tarkistaa kaikkien tiedostojen yhteensopivuuden
const stepV = step8 + 1;
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${stepV}] QA</span> — validointi`);
pipelineStep('qa', 'Validointi', 'active', 'Tarkistetaan yhteensopivuus');
const allFiles = Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n');
const validatePrompt = `You are a QA engineer. Check EVERY item below and report the result for each. Use this EXACT format:
1. Dockerfile COPY: ✓ OK / ✗ problem description
2. Dockerfile deps vs imports: ✓ OK / ✗ problem description
3. docker-compose ports: ✓ OK / ✗ problem description
4. README commands: ✓ OK / ✗ problem description
5. Test imports: ✓ OK / ✗ problem description
6. pyproject.toml deps: ✓ OK / ✗ problem (NEVER list stdlib: sqlite3, os, sys, json, typing)
EXAMPLE output:
1. Dockerfile COPY: ✓ OK — copies main.py and models.py which both exist
2. Dockerfile deps: ✗ missing "sqlalchemy" in pip install
3. docker-compose ports: ✓ OK — maps 8000:8000 matching EXPOSE
4. README commands: ✓ OK — uvicorn main:app matches main.py
5. Test imports: ✓ OK — imports main.app which exists
6. pyproject.toml deps: ✓ OK — includes fastapi, uvicorn, sqlalchemy
Files in project: ${Object.keys(generatedFiles).join(', ')}
${allFiles}`;
const validation = await kpnRun(agentPrompts.qa.model, validatePrompt, false, 256);
pipelineStep('qa', 'Validointi', 'done', 'Yhteensopivuus', validation);
// Jos QA löysi ongelmia, korjataan
if (validation && !validation.toLowerCase().startsWith('ok') && !validation.toLowerCase().includes('no issues') && !validation.toLowerCase().includes('everything is fine')) {
const stepFix = stepV + 1;
termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepFix}] DevOps</span> — korjaukset`);
pipelineStep('tester', 'Korjaukset', 'active', validation);
// Korjataan vain Dockerfile ja docker-compose
// Korjataan koodatiedostot (ei Dockerfilea — se on template)
const fixableFiles = Object.entries(generatedFiles)
.filter(([n]) => n.endsWith('.py') || n === 'pyproject.toml')
.map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n');
const fixPrompt = `Fix the code files based on this feedback. Output corrected code only.
Do NOT output Dockerfile or docker-compose.yml — those are auto-generated.
Feedback: ${validation}
${fixableFiles}`;
const fixedCode = await kpnRun(agentPrompts.coder.model, fixPrompt, false, 512);
// Ei ylikirjoiteta Dockerfilea — generoidaan template uudelleen
if (fixedCode) {
termLog(` <span style="color:#8b949e">Korjaukset generoitu</span>`);
}
pipelineStep('coder', 'Korjaukset', 'done', 'Korjaukset generoitu', fixedCode);
}
// Generoidaan projektin dokumentaatio HTML-raporttina
const reportHtml = generateProjectReport(task, generatedFiles, pipelineSteps, staticIssues);
const reportBlob = new Blob([reportHtml], { type: 'text/html' });
const reportUrl = URL.createObjectURL(reportBlob);
// Pipeline valmis — sammutetaan kaikki avataret
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('active'));
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis (${Object.keys(generatedFiles).length} tiedostoa) ━━━</span>`);
termLog(` <a href="${reportUrl}" target="_blank" style="color:#58a6ff;text-decoration:underline;cursor:pointer">📄 Avaa projektiraportti</a>`);
renderProjectCard(generatedFiles, task, reportUrl);
}
function generateProjectReport(task, files, steps, staticIssues) {
const fileEntries = Object.entries(files);
const agentNames = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data', tofuist: 'Tofuist' };
const agentColors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff', tofuist: '#e3a336' };
// Syntaksikorostus: kevyt regex-pohjainen highlighter
function highlightCode(code, filename) {
let h = code.replace(/&/g,'&amp;').replace(/</g,'&lt;');
if (filename.endsWith('.py') || filename.endsWith('.toml') || filename.endsWith('.yml') || filename.endsWith('.yaml')) {
// Kommentit
h = h.replace(/(#[^\n]*)/g, '<span style="color:#8b949e;font-style:italic">$1</span>');
// Stringit (kolmois- ja yksittäiset)
h = h.replace(/("""[\s\S]*?"""|'''[\s\S]*?''')/g, '<span style="color:#a5d6ff">$1</span>');
h = h.replace(/((?<![\\])(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))/g, '<span style="color:#a5d6ff">$1</span>');
// Avainsanat
h = h.replace(/\b(def|class|import|from|return|if|elif|else|for|in|while|try|except|finally|with|as|raise|yield|async|await|True|False|None|not|and|or|is|lambda)\b/g, '<span style="color:#ff7b72">$1</span>');
// Dekoraattorit
h = h.replace(/^(\s*@\w+)/gm, '<span style="color:#d2a8ff">$1</span>');
// Numerot
h = h.replace(/\b(\d+\.?\d*)\b/g, '<span style="color:#79c0ff">$1</span>');
} else if (filename.endsWith('.html')) {
h = h.replace(/(&lt;\/?[\w-]+)/g, '<span style="color:#7ee787">$1</span>');
h = h.replace(/([\w-]+)=/g, '<span style="color:#79c0ff">$1</span>=');
h = h.replace(/((?<!=)"[^"]*")/g, '<span style="color:#a5d6ff">$1</span>');
} else if (filename === 'Dockerfile') {
h = h.replace(/^(FROM|RUN|COPY|WORKDIR|EXPOSE|CMD|ENV|ARG|ENTRYPOINT|ADD|VOLUME|LABEL|USER)/gm, '<span style="color:#ff7b72">$1</span>');
h = h.replace(/(#[^\n]*)/g, '<span style="color:#8b949e;font-style:italic">$1</span>');
h = h.replace(/((?<!=)"[^"]*")/g, '<span style="color:#a5d6ff">$1</span>');
}
return h;
}
const stepsHtml = steps.map((s, i) => {
const color = agentColors[s.agent] || '#8b949e';
const icon = s.status === 'done' ? '✓' : '◷';
const outputPreview = (s.output || '').substring(0, 500);
const highlighted = outputPreview ? highlightCode(outputPreview, s.label) : '';
return `
<div style="margin-bottom:12px;border:1px solid #30363d;border-radius:8px;overflow:hidden">
<div style="background:#161b22;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d">
<span><span style="color:${s.status === 'done' ? '#3fb950' : '#d29922'};font-size:14px">${icon}</span> <strong style="color:${color}">${agentNames[s.agent] || s.agent}</strong> <span style="color:#8b949e">—</span> ${s.label}</span>
<span style="color:#484f58;font-size:11px;background:#0d1117;padding:2px 8px;border-radius:10px">Vaihe ${i + 1}</span>
</div>
${s.input ? `<details><summary style="padding:6px 14px;color:#8b949e;font-size:12px;cursor:pointer;border-bottom:1px solid #21262d">▸ Prompti</summary><pre style="margin:0;padding:10px 14px;background:#010409;font-size:11px;overflow-x:auto;white-space:pre-wrap;color:#8b949e;line-height:1.5">${s.input.replace(/</g,'&lt;').substring(0, 1000)}</pre></details>` : ''}
${highlighted ? `<pre style="margin:0;padding:10px 14px;background:#0d1117;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9;line-height:1.6">${highlighted}</pre>` : ''}
</div>`;
}).join('');
const filesHtml = fileEntries.map(([name, content]) => {
const ext = name.split('.').pop();
const langLabel = {py:'Python',html:'HTML',toml:'TOML',yml:'YAML',yaml:'YAML',md:'Markdown'}[ext] || ext.toUpperCase();
const lines = (content || '').split('\\n').length;
return `
<div style="margin-bottom:12px;border:1px solid #30363d;border-radius:8px;overflow:hidden">
<div style="background:#161b22;padding:8px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d">
<span style="font-weight:600;color:#58a6ff">${name}</span>
<span style="color:#484f58;font-size:11px">${langLabel} · ${lines} riviä</span>
</div>
<pre style="margin:0;padding:12px 14px;background:#010409;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9;line-height:1.6">${highlightCode(content || '', name)}</pre>
</div>`;
}).join('');
const staticHtml = (staticIssues || []).length > 0
? `<div style="margin-bottom:16px;padding:12px;background:#1c1206;border:1px solid #d29922;border-radius:6px">
<strong style="color:#d29922">Staattinen analyysi (${staticIssues.length} huomautusta)</strong>
<ul style="margin:8px 0 0;padding-left:20px;color:#d29922">${staticIssues.map(i => `<li>${i}</li>`).join('')}</ul>
</div>`
: '<p style="color:#3fb950">✓ Staattinen analyysi: ei huomautuksia</p>';
return `<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kipinä Raportti — ${task}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; max-width: 960px; margin: 0 auto; line-height: 1.5; }
h1 { color: #f0f6fc; margin-bottom: 4px; font-size: 24px; }
h2 { color: #c9d1d9; margin: 28px 0 14px; border-bottom: 1px solid #30363d; padding-bottom: 8px; font-size: 18px; }
h3 { color: #8b949e; margin: 16px 0 8px; }
pre { font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace; tab-size: 4; }
a { color: #58a6ff; }
details summary { list-style: none; cursor: pointer; }
details summary::-webkit-details-marker { display: none; }
details[open] summary { color: #58a6ff; }
.sl-badge { display: inline-block; border: 1px solid; border-radius: 6px; padding: 3px 10px; margin: 2px; font-size: 11px; cursor: help; white-space: nowrap; position: relative; transition: transform 0.15s; }
.sl-badge:hover { transform: translateY(-1px); filter: brightness(1.3); }
.sl-badge[data-tip]:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: #1c2028; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; font-size: 11px; color: #c9d1d9; white-space: pre-wrap; max-width: 350px; min-width: 180px; z-index: 10; box-shadow: 0 4px 12px rgba(0,0,0,0.5); pointer-events: none; line-height: 1.5; }
</style>
</head>
<body>
<h1>🔥 Kipinä Projektiraportti</h1>
<p style="color:#8b949e;margin-bottom:20px">${task}${new Date().toLocaleString('fi-FI')}${fileEntries.length} tiedostoa, ${steps.length} vaihetta</p>
<h2>🔄 Agenttien workflow</h2>
${generateWorkflowSwimlane(steps)}
<h2>📋 Pipeline-vaiheet</h2>
${stepsHtml}
<h2>🔍 Staattinen analyysi</h2>
${staticHtml}
<h2>📁 Tiedostot</h2>
${filesHtml}
<hr style="border-color:#30363d;margin:24px 0">
<p style="color:#8b949e;font-size:12px">Generoitu Kipinä Agentic Playground v0.2.4 — <a href="https://kipina.studio">kipina.studio</a></p>
</body></html>`;
}
function generateWorkflowSwimlane(steps) {
const agentLabels = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data', tofuist: 'Tofuist' };
const agentColors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff', tofuist: '#e3a336' };
const agentBgs = { manager: '#1c1206', coder: '#0d1a0d', tester: '#0d1520', qa: '#170d22', data: '#1a0d22', tofuist: '#1a1506' };
const stepDescs = { 'Suunnittelu': 'Jakaa projektin tiedostoiksi', 'Review': 'Tarkistaa koodin laadun', 'Testit': 'Kirjoittaa pytest-testit', 'Dockerfile': 'Generoi Docker-imagen', 'Compose': 'Palvelumääritys', 'README': 'Käyttöohjeet', 'Validointi': 'Tarkistaa yhteensopivuuden', 'Korjaukset': 'Korjaa löydetyt ongelmat' };
var agents = [];
var agentMap = {};
for (var si = 0; si < steps.length; si++) {
var s = steps[si];
if (!agentMap[s.agent]) { agentMap[s.agent] = []; agents.push(s.agent); }
agentMap[s.agent].push(s);
}
var rows = '';
for (var ai = 0; ai < agents.length; ai++) {
var agent = agents[ai];
var color = agentColors[agent] || '#8b949e';
var bg = agentBgs[agent] || '#161b22';
var label = agentLabels[agent] || agent;
var badges = '';
var aSteps = agentMap[agent];
for (var bi = 0; bi < aSteps.length; bi++) {
var st = aSteps[bi];
var desc = stepDescs[st.label] || st.label;
var outPrev = (st.output || '').substring(0, 150).replace(/</g, '&lt;').replace(/"/g, '&quot;');
// Rivitys tooltipiin: jokainen rivi omalle rivilleen
var tipLines = [desc];
if (outPrev) {
tipLines.push('');
var outLines = outPrev.split(/\n/).slice(0, 6);
for (var li = 0; li < outLines.length; li++) tipLines.push(outLines[li]);
if ((st.output || '').split(/\n/).length > 6) tipLines.push('...');
}
var icon = st.status === 'done' ? '✓' : '◷';
if (bi > 0) badges += '<span style="color:#484f58;margin:0 2px">→</span>';
badges += '<span class="sl-badge" style="background:' + bg + ';border-color:' + color + ';color:' + color + '" data-tip="' + tipLines.join('&#10;') + '">' + icon + ' ' + st.label.replace(/</g, '&lt;') + '</span>';
}
rows += '<div style="display:flex;align-items:center;gap:10px;margin:5px 0;padding:4px 0;border-bottom:1px solid #21262d"><span style="min-width:70px;font-weight:600;font-size:12px;color:' + color + '">' + label + '</span><div style="display:flex;flex-wrap:wrap;align-items:center;gap:2px">' + badges + '</div></div>';
}
return '<div style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:14px 16px">' + rows + '</div>';
}
// Yksinkertainen pipeline (vanha: manageri → koodari → testaaja)
async function kpnPipelineSimple(task) {
termLog(`<span style="color:#a371f7;font-weight:bold">━━━ Pipeline käynnistyy ━━━</span>`);
termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span>`);
const plan = await kpnRun(agentPrompts.manager.model, `Analyse this task briefly and write a technical spec for a coder:\n${task}`);
if (!plan) return;
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span>`);
const code = await kpnRun(agentPrompts.coder.model, `${plan}\n\nWrite the code.`);
if (!code) return;
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[3/3] Testaaja</span>`);
await kpnRun(agentPrompts.tester.model, `Review briefly:\n${code}`);
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`);
}
// Autokorjaus: tunnetut kirjoitusvirheet ja lähimmän komennon ehdotus
function autocorrect(input) {
const typos = {
'knp': 'kpn', 'kpb': 'kpn', 'kpm': 'kpn', 'kn': 'kpn', 'kp': 'kpn',
'kpn rnu': 'kpn run', 'kpn rn': 'kpn run', 'kpn ru': 'kpn run',
'kpn laod': 'kpn load', 'kpn lod': 'kpn load', 'kpn loa': 'kpn load',
'kpn porject': 'kpn project', 'kpn projcet': 'kpn project', 'kpn proejct': 'kpn project',
'kpn pipelien': 'kpn pipeline', 'kpn pipline': 'kpn pipeline',
'kpn staus': 'kpn status', 'kpn stauts': 'kpn status',
'kpn modles': 'kpn models', 'kpn mdoels': 'kpn models',
'kpn hlep': 'kpn help', 'kpn hep': 'kpn help',
'kpn clera': 'kpn clear', 'kpn claer': 'kpn clear',
'kpn helo': 'kpn hello', 'kpn hell': 'kpn hello',
};
// Tarkista koko komento ja ensimmäinen sana + alikomento
const lower = input.toLowerCase();
for (const [typo, fix] of Object.entries(typos)) {
if (lower === typo || lower.startsWith(typo + ' ')) {
return fix + input.slice(typo.length);
}
}
// Levenshtein-etäisyys ensimmäiselle sanalle
const words = input.trim().split(/\s+/);
const firstWord = words[0].toLowerCase();
if (firstWord !== 'kpn' && firstWord.length >= 2 && firstWord.length <= 5) {
const dist = levenshtein(firstWord, 'kpn');
if (dist <= 2) return 'kpn' + input.slice(firstWord.length);
}
// Fuzzy-korjaus alikomentotasolla: "kpn rnu" → "kpn run"
if (firstWord === 'kpn' && words.length >= 2) {
const sub = words[1].toLowerCase();
const subCommands = ['help', 'run', 'project', 'pipeline', 'load', 'status', 'models', 'hello', 'clear'];
let bestMatch = null, bestDist = 3;
for (const cmd of subCommands) {
const d = levenshtein(sub, cmd);
if (d > 0 && d < bestDist) { bestDist = d; bestMatch = cmd; }
}
if (bestMatch) {
words[1] = bestMatch;
return words.join(' ');
}
}
return null;
}
function levenshtein(a, b) {
const m = a.length, n = b.length;
const d = Array.from({length: m + 1}, (_, i) => [i]);
for (let j = 1; j <= n; j++) d[0][j] = j;
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
d[i][j] = Math.min(d[i-1][j] + 1, d[i][j-1] + 1, d[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0));
return d[m][n];
}
function termExec(cmd) {
termLog(`<span class="terminal-prompt">$</span> ${esc(cmd)}`);
termHistory.unshift(cmd);
termHistIdx = -1;
// Autokorjaus
const corrected = autocorrect(cmd.trim());
if (corrected && corrected !== cmd.trim()) {
cmd = corrected;
termLog(` <span style="color:#d29922">→ korjattu: ${esc(cmd)}</span>`);
}
// Oikotie: pelkkä numero → kpn load <numero>
if (/^\d+$/.test(cmd.trim())) {
cmd = 'kpn load ' + cmd.trim();
termLog(` <span style="color:#d29922">→ ${esc(cmd)}</span>`);
}
const parts = cmd.trim().split(/\s+/);
if (parts[0] !== 'kpn') {
termLog('kpn: tuntematon komento. Kokeile: kpn help', '#f85149');
return;
}
const sub = parts[1];
if (sub === 'help' || !sub) {
termLog(' kpn hello — iloinen tervehdys verkosta', '#a5d6ff');
termLog(' kpn run &lt;malli&gt; "&lt;prompti&gt;" — aja tehtävä verkossa', '#a5d6ff');
termLog(' kpn pipeline "&lt;tehtävä&gt;" — nopea: manageri → koodari → testaaja', '#a5d6ff');
termLog(' kpn project "&lt;kuvaus&gt;" — projekti: tiedostojako + generointi + review', '#a5d6ff');
termLog(' kpn load — lataa kielimalli omalle koneelle', '#a5d6ff');
termLog(' kpn status — verkon tila', '#a5d6ff');
termLog(' kpn models — käytettävissä olevat mallit', '#a5d6ff');
termLog(' kpn clear — tyhjennä terminaali', '#a5d6ff');
return;
}
if (sub === 'clear') {
termPanel.innerHTML = '';
return;
}
if (sub === 'load') {
const arg = parts[2];
const ollamaModels = [
{ id: '1', name: 'qwen2.5-coder:0.5b', size: '~400 MB', vram_mb: 0, type: 'selain + Ollama' },
{ id: '2', name: 'qwen2.5-coder:1.5b', size: '~1 GB', vram_mb: 1500, type: 'Ollama GPU' },
{ id: '3', name: 'qwen2.5-coder:7b', size: '~4.7 GB', vram_mb: 5500, type: 'Ollama GPU', default: true },
{ id: '4', name: 'qwen2.5-coder:14b', size: '~9 GB', vram_mb: 10000, type: 'Ollama GPU' },
{ id: '5', name: 'qwen2.5-coder:32b', size: '~20 GB', vram_mb: 21000, type: 'Ollama GPU' },
];
if (!arg) {
// Haetaan laitteistotiedot ja näytetään sopivat mallit
fetch('/api/v1/hardware').then(r => r.json()).then(hw => {
const vram = hw.vram_mb || 0;
const ram = hw.ram_mb || 0;
const gpu = hw.gpu_name || '?';
const available = vram || ram; // CPU-fallback käyttää RAM:ia
if (vram > 0) {
termLog(` <span style="color:#8b949e">GPU: ${gpu} | VRAM: ${Math.round(vram/1024)} GB | RAM: ${Math.round(ram/1024)} GB</span>`);
} else if (ram > 0) {
termLog(` <span style="color:#8b949e">Ei GPU:ta | RAM: ${Math.round(ram/1024)} GB (CPU-moodi)</span>`);
}
termLog(' Mallit:', '#c9d1d9');
for (const m of ollamaModels) {
const fits = m.vram_mb === 0 || m.vram_mb < available;
const active = m.default ? ' <span style="color:#3fb950">← aktiivinen</span>' : '';
const icon = fits ? `<span style="color:#58a6ff">${m.id}</span>` : `<span style="color:#8b949e;text-decoration:line-through">${m.id}</span>`;
const warn = !fits ? ' <span style="color:#f85149">⚠ ei mahdu</span>' : '';
termLog(` ${icon} ${fits ? '' : '<span style="color:#8b949e">'}${m.name} ${m.size} | ${m.type}${fits ? '' : '</span>'}${active}${warn}`);
}
termLog(' Käyttö: kpn load &lt;numero&gt;', '#8b949e');
}).catch(() => {
termLog(' Mallit:', '#c9d1d9');
for (const m of ollamaModels) {
const active = m.default ? ' <span style="color:#3fb950">← aktiivinen</span>' : '';
termLog(` <span style="color:#58a6ff">${m.id}</span> ${m.name} <span style="color:#8b949e">${m.size} | ${m.type}</span>${active}`);
}
termLog(' Käyttö: kpn load &lt;numero&gt;', '#8b949e');
});
return;
}
const selected = ollamaModels.find(m => m.id === arg || m.name === arg);
if (!selected) {
termLog(` Tuntematon malli "${esc(arg)}". Kokeile: kpn load`, '#f85149');
return;
}
// Selain-WASM (vain 0.5b)
if (selected.id === '1') {
const btn = document.getElementById('agent-compute-btn');
if (btn?.dataset.state === 'ready') {
termLog(' ✓ Qwen2.5-Coder:0.5B on jo ladattu (selain)', '#3fb950');
return;
}
coderSize = '05b';
termLog(' Ladataan Qwen2.5-Coder:0.5B selaimeen...', '#d29922');
if (btn) btn.click();
else ensureCoderNode();
return;
}
// Ollama: vaihdetaan malli hubin kautta
termLog(` Vaihdetaan Ollama-malli: ${selected.name} (${selected.size})...`, '#d29922');
// Tilaindikaattori
const pullLine = document.createElement('div');
pullLine.className = 'terminal-line term-pull';
pullLine.innerHTML = ' <span style="color:#d29922">⠋ Ladataan...</span>';
termPanel.appendChild(pullLine);
termPanel.scrollTop = termPanel.scrollHeight;
const spinFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
let spinIdx = 0;
const spinTimer = setInterval(() => {
spinIdx = (spinIdx + 1) % spinFrames.length;
const content = pullLine.querySelector('span');
if (content) content.textContent = spinFrames[spinIdx] + ' Ladataan ' + selected.name + '...';
}, 100);
// Vaihdetaan malli hubille + Ollama pull
fetch('/api/v1/model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: selected.name }),
}).then(r => r.json()).then(hubData => {
clearInterval(spinTimer);
pullLine.remove();
if (hubData.status === 'ok') {
termLog(` <span style="color:#3fb950">✓</span> ${selected.name} valittu — natiivisolmu lataa mallin`, '#3fb950');
termLog(` <span style="color:#8b949e">Ensimmäinen pyyntö voi kestää pidempään jos mallia ei ole ladattu</span>`);
ollamaModels.forEach(m => m.default = false);
selected.default = true;
} else {
termLog(' ✗ Mallin vaihto epäonnistui', '#f85149');
}
}).catch(e => {
clearInterval(spinTimer);
pullLine.remove();
termLog(`${e.message}`, '#f85149');
});
return;
}
if (sub === 'status') {
const nodes = statNodes.textContent || '0';
const vram = statVram.textContent || '?';
termLog(` Solmuja: ${nodes} | VRAM: ${vram} | Tehtäviä: ${statTasks.textContent || '0'}`, '#a5d6ff');
return;
}
if (sub === 'models') {
const allModels = [
{ id: '1', name: 'qwen2.5-coder:0.5b', size: '~400 MB', type: 'selain + Ollama' },
{ id: '2', name: 'qwen2.5-coder:1.5b', size: '~1 GB', type: 'Ollama GPU' },
{ id: '3', name: 'qwen2.5-coder:7b', size: '~4.7 GB', type: 'Ollama GPU' },
{ id: '4', name: 'qwen2.5-coder:14b', size: '~9 GB', type: 'Ollama GPU' },
{ id: '5', name: 'qwen2.5-coder:32b', size: '~20 GB', type: 'Ollama GPU' },
];
// Haetaan ladatut mallit Ollamasta
Promise.all([
fetch('/api/v1/hardware').then(r => r.json()).catch(() => ({})),
fetch('/api/v1/ollama/tags').then(r => r.json()).catch(() => ({ models: [] })),
]).then(([hw, ollama]) => {
const loadedNames = (ollama.models || []).map(m => m.name.replace(':latest', ''));
const btn = document.getElementById('agent-compute-btn');
const wasmLoaded = btn?.dataset.state === 'ready';
if (hw.gpu_name && hw.gpu_name !== 'ei natiivisolmua') {
const vram = hw.vram_mb ? ` | VRAM: ${Math.round(hw.vram_mb/1024)} GB` : '';
termLog(` <span style="color:#8b949e">${hw.gpu_name}${vram}</span>`);
}
if (loadedNames.length > 0) {
termLog(` <span style="color:#3fb950">Ollama: ${loadedNames.length} mallia ladattu</span>`);
}
termLog(' Mallit <span style="color:#8b949e">(kpn load &lt;numero&gt;)</span>:', '#c9d1d9');
for (const m of allModels) {
const nameBase = m.name.split(':')[1]; // "7b", "1.5b" etc
const loaded = (m.id === '1' && wasmLoaded) || loadedNames.some(n => n.includes(nameBase));
const status = loaded ? ' <span style="color:#3fb950">✓ ladattu</span>' : '';
termLog(` <span style="color:#58a6ff">${m.id}</span> ${m.name} <span style="color:#8b949e">${m.size} | ${m.type}</span>${status}`);
}
});
return;
}
if (sub === 'pipeline') {
const afterCmd = cmd.replace(/^kpn\s+pipeline\s*/, '');
const pMatch = afterCmd.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
const pTask = (pMatch && (pMatch[1] || pMatch[2] || pMatch[3] || '')).trim();
if (!pTask) {
termLog(' Käyttö: kpn pipeline "&lt;tehtävä&gt;"', '#f85149');
return;
}
kpnPipelineSimple(pTask);
return;
}
if (sub === 'project') {
const afterCmd = cmd.replace(/^kpn\s+project\s*/, '');
const pMatch = afterCmd.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
const pTask = (pMatch && (pMatch[1] || pMatch[2] || pMatch[3] || '')).trim();
if (!pTask) {
termLog(' Käyttö: kpn project "&lt;projektin kuvaus&gt;"', '#f85149');
termLog(' Esim: kpn project "FastAPI + SQLite REST API for users"', '#8b949e');
return;
}
kpnPipeline(pTask);
return;
}
if (sub === 'hello') {
kpnRun('smollm-135m', 'Tervehdi käyttäjää iloisesti ja lyhyesti suomeksi. Ole innostunut ja energinen! Vastaa yhdellä lauseella.');
return;
}
if (sub === 'run') {
let model = parts[2];
const afterModel = cmd.replace(/^kpn\s+run\s+\S+\s*/, '');
const promptMatch = afterModel.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
const prompt = (promptMatch && (promptMatch[1] || promptMatch[2] || promptMatch[3] || '')).trim();
if (!model || !prompt) {
termLog(' Käyttö: kpn run &lt;agentti/malli&gt; "&lt;prompti&gt;"', '#f85149');
return;
}
// Jos käyttäjä syötti agentin nimen (esim. "coder"), vaihdetaan se oikeaksi tekoälymalliksi ("qwen-coder")
if (model === 'coder-3b') {
model = 'qwen-coder-3b';
} else if (agentPrompts[model]) {
model = agentPrompts[model].model;
}
kpnRun(model, prompt);
return;
}
termLog(` kpn: tuntematon alikomento "${sub}". Kokeile: kpn help`, '#f85149');
}
// Tab-completion: ennustava komennonsyöttö sana kerrallaan
const kpnCommands = {
'kpn': ['help', 'run', 'project', 'pipeline', 'load', 'status', 'models', 'hello', 'clear'],
'kpn run': ['coder', 'coder-3b', 'manager', 'tester', 'qa', 'data', 'observer', 'qwen-coder', 'qwen-coder-3b', 'smollm-135m', 'qwen-05b', 'phi3-mini'],
'kpn load': ['1', '2'],
'kpn pipeline': ['"'],
};
// Esimerkkipromptit malleittain
const kpnExamples = {
'kpn run coder': ['"hello world in python"', '"fibonacci in rust"', '"quicksort in javascript"'],
'kpn run coder-3b': ['"binary search tree in rust"', '"REST API with Flask"', '"async web scraper in python"'],
'kpn run manager': ['"suunnittele REST API"', '"priorisoi tiimin tehtävät"'],
'kpn run tester': ['"testaa login-toiminto"'],
'kpn project': ['"FastAPI + SQLite CRUD API for users (create, list, update, delete) with HTML form UI served at /"', '"FastAPI todo app with SQLite: CRUD (add, list, toggle done, delete) with simple HTML UI at /"', '"FastAPI bookmark manager with SQLite: CRUD (add, list, edit, delete) with HTML search UI at /"'],
'kpn pipeline': ['"rakenna todo-sovellus"', '"tee laskin pythonilla"'],
};
function tabComplete(input) {
// Autokorjaus ensin: korjaa typo ja palauta true jos korjattiin
const corrected = autocorrect(input.value.trim());
if (corrected && corrected !== input.value.trim()) {
input.value = corrected;
return true;
}
const val = input.value;
const words = val.trimEnd().split(/\s+/);
// Etsitään sopiva täydennystaso
// "kpn" → "kpn " alikomennot, "kpn run" → mallit, "kpn run coder" → prompti
for (let depth = words.length; depth >= 1; depth--) {
const prefix = words.slice(0, depth).join(' ');
const partial = words[depth] || '';
// Tarkistetaan esimerkkipromptit ensin
if (kpnExamples[prefix] && !partial) {
const example = kpnExamples[prefix][Math.floor(Math.random() * kpnExamples[prefix].length)];
input.value = prefix + ' ' + example;
return true;
}
// Komentojen täydennys
const candidates = kpnCommands[prefix];
if (candidates) {
const matches = partial
? candidates.filter(c => c.startsWith(partial))
: candidates;
if (matches.length === 1) {
words[depth] = matches[0];
input.value = words.slice(0, depth + 1).join(' ') + ' ';
return true;
} else if (matches.length > 1 && !partial) {
input.value = prefix + ' ' + matches[0];
return true;
} else if (matches.length > 1) {
// Yhteinen etuliite
let common = matches[0];
for (const m of matches) {
while (!m.startsWith(common)) common = common.slice(0, -1);
}
if (common.length > partial.length) {
words[depth] = common;
input.value = words.slice(0, depth + 1).join(' ');
return true;
}
}
}
}
// Tyhjä input → "kpn "
if (!val.trim()) {
input.value = 'kpn ';
return true;
}
return false;
}
// Dropdown-autocompletionin tila
const dropdown = document.getElementById('term-dropdown');
let dropdownItems = [];
let dropdownIdx = -1;
let dropdownPrefix = ''; // Inputin alku joka säilyy valinnan yhteydessä
function getCandidates(val) {
const words = val.trimEnd().split(/\s+/);
for (let depth = words.length; depth >= 1; depth--) {
const prefix = words.slice(0, depth).join(' ');
const partial = words[depth] || '';
// Esimerkkipromptit
if (kpnExamples[prefix] && !partial) {
return { items: kpnExamples[prefix], prefix: prefix + ' ' };
}
// Komennot
const candidates = kpnCommands[prefix];
if (candidates) {
const matches = partial ? candidates.filter(c => c.startsWith(partial)) : candidates;
if (matches.length > 0) {
return { items: matches, prefix: prefix + ' ' };
}
}
}
if (!val.trim()) return { items: kpnCommands['kpn'] || [], prefix: 'kpn ' };
return { items: [], prefix: val };
}
function showDropdown(items, prefix) {
if (!dropdown || items.length === 0) { hideDropdown(); return; }
dropdownItems = items;
dropdownPrefix = prefix;
dropdownIdx = -1;
dropdown.innerHTML = items.map((item, i) =>
`<div class="term-dd-item" data-idx="${i}" style="padding:6px 12px;cursor:pointer;color:#c9d1d9;white-space:nowrap;border-bottom:1px solid #21262d">${esc(item)}</div>`
).join('');
dropdown.style.display = 'block';
// Klikkaus-handlerit
dropdown.querySelectorAll('.term-dd-item').forEach(el => {
el.addEventListener('mouseenter', () => highlightDropdown(parseInt(el.dataset.idx)));
el.addEventListener('click', () => { selectDropdown(); termInput.focus(); });
});
}
function hideDropdown() {
if (dropdown) { dropdown.style.display = 'none'; dropdown.innerHTML = ''; }
dropdownItems = [];
dropdownIdx = -1;
}
function highlightDropdown(idx) {
dropdownIdx = idx;
dropdown.querySelectorAll('.term-dd-item').forEach((el, i) => {
el.style.background = i === idx ? '#30363d' : 'transparent';
el.style.color = i === idx ? '#58a6ff' : '#c9d1d9';
});
// Varmistetaan näkyvyys
const active = dropdown.children[idx];
if (active) active.scrollIntoView({ block: 'nearest' });
}
function selectDropdown() {
if (dropdownIdx >= 0 && dropdownIdx < dropdownItems.length) {
termInput.value = dropdownPrefix + dropdownItems[dropdownIdx] + (dropdownItems[dropdownIdx].startsWith('"') ? '' : ' ');
}
hideDropdown();
}
termInput?.addEventListener('keydown', (e) => {
// Dropdown auki: nuolet navigoi, Enter/Tab valitsee, Esc sulkee
if (dropdown && dropdown.style.display === 'block') {
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightDropdown(Math.min(dropdownIdx + 1, dropdownItems.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
highlightDropdown(Math.max(dropdownIdx - 1, 0));
return;
}
if ((e.key === 'Enter' || e.key === 'Tab') && dropdownIdx >= 0) {
e.preventDefault();
selectDropdown();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideDropdown();
return;
}
}
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
hideDropdown();
const val = termInput.value.trimEnd();
if (!val) return;
const quoteMatch = val.match(/^(.+\s)".*"?$|^(.+\s)'.*'?$/);
if (quoteMatch) {
termInput.value = (quoteMatch[1] || quoteMatch[2]).trimEnd() + ' ';
} else {
const lastSpace = val.lastIndexOf(' ');
termInput.value = lastSpace > 0 ? val.substring(0, lastSpace + 1) : '';
}
} else if (e.key === 'Tab') {
e.preventDefault();
// 1. Autokorjaus ensin
const corrected = autocorrect(termInput.value.trim());
if (corrected && corrected !== termInput.value.trim()) {
termInput.value = corrected;
hideDropdown();
return;
}
// 2. Dropdown / täydennys
const { items, prefix } = getCandidates(termInput.value);
if (items.length === 1) {
termInput.value = prefix + items[0] + (items[0].startsWith('"') ? '' : ' ');
hideDropdown();
} else if (items.length > 1) {
showDropdown(items, prefix);
}
} else if (e.key === 'Enter') {
hideDropdown();
const cmd = termInput.value.trim();
if (cmd) termExec(cmd);
termInput.value = '';
} else if (e.key === 'ArrowUp' && !dropdown?.style.display?.includes('block')) {
e.preventDefault();
if (termHistIdx < termHistory.length - 1) {
termHistIdx++;
termInput.value = termHistory[termHistIdx];
}
} else if (e.key === 'ArrowDown' && !dropdown?.style.display?.includes('block')) {
e.preventDefault();
if (termHistIdx > 0) {
termHistIdx--;
termInput.value = termHistory[termHistIdx];
} else {
termHistIdx = -1;
termInput.value = '';
}
}
});
// Suljetaan dropdown kun klikataan muualle
document.addEventListener('click', (e) => {
if (!termInput?.contains(e.target) && !dropdown?.contains(e.target)) hideDropdown();
});
// Klikkaa terminaalipaneelia → fokusoi input
termPanel?.addEventListener('click', () => termInput?.focus());
// Tallennetaan message-handler funktioon jotta reconnect voi käyttää samaa
const _wsHandler = (event) => {
try {
const raw = event.data;
if (raw.includes('"single_tokenize"')) return;
const data = JSON.parse(raw);
if (data.type === "stats") {
statNodes.textContent = data.nodes;
statVram.textContent = data.vram_gb + " GB";
if (data.tasks !== undefined) {
statTasks.textContent = data.tasks;
}
if (data.version) {
document.getElementById('hub-version').textContent = 'v' + data.version;
}
} else if (data.type === "node_joined") {
chatBox.classList.remove('hidden');
} else if (data.type === "download_progress") {
const dlBar = document.getElementById('download-bar');
if (data.pct < 100) {
dlBar.style.display = 'block';
document.getElementById('dl-label').textContent = `Ladataan: ${data.file}`;
document.getElementById('dl-pct').textContent = data.pct + '%';
document.getElementById('dl-fill').style.width = data.pct + '%';
document.getElementById('dl-detail').textContent = `${data.loaded_mb} / ${data.total_mb} MB`;
} else {
dlBar.style.display = 'none';
}
// Terminaaliin latauksen edistyminen
const term = document.getElementById('agent-terminal');
if (term) {
let dlLine = term.querySelector('.term-download');
if (data.pct >= 100) {
if (dlLine) dlLine.remove();
termLog(` <span style="color:#3fb950">✓</span> ${data.file} ladattu`, '#a5d6ff');
} else {
if (!dlLine) {
dlLine = document.createElement('div');
dlLine.className = 'terminal-line term-download';
term.appendChild(dlLine);
}
const bar = '█'.repeat(Math.floor(data.pct / 5)) + '░'.repeat(20 - Math.floor(data.pct / 5));
dlLine.innerHTML = ` <span style="color:#d29922">${data.file}</span> <span style="color:#8b949e">${bar}</span> <span style="color:#58a6ff">${data.pct}%</span> <span style="color:#8b949e">${data.loaded_mb}/${data.total_mb} MB</span>`;
term.scrollTop = term.scrollHeight;
}
}
} else if (data.type === "single_tokenize_done") {
chatBox.classList.remove('hidden');
const r = data.result || {};
const ms = data.duration_ms || 0;
const nodeId = data.node_id || '?';
const cpt = parseFloat((r.chars_per_token || 0).toFixed(2));
const cptColor = cpt >= 4 ? "#3fb950" : cpt >= 3 ? "#d29922" : "#f85149";
const renderTokens = (tokens) => (tokens || []).map(t =>
`<span class="tok tok-en">${esc(t)}</span>`
).join('');
const tokHtml = renderTokens(r.tokens);
const detailId = 'stok-' + Date.now();
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-msg';
msgDiv.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="color:var(--accent-color);font-weight:600;font-size:15px">Solmu #${nodeId}</span>
<div style="display:flex;gap:8px;align-items:center">
<button class="toggle-tokens" onclick="document.getElementById('${detailId}').classList.toggle('visible')">Tokenit</button>
<span style="color:#8b949e;font-size:13px">${typeof ms === 'number' ? ms.toFixed(2) : ms}ms</span>
</div>
</div>
<div style="font-size:14px;color:#79b8ff;margin-bottom:6px">"${esc(r.text)}"</div>
<div style="font-size:14px;display:flex;gap:16px">
<span style="color:#8b949e">${r.char_count || 0} merkkiä</span>
<span style="color:#8b949e">${r.word_count || 0} sanaa</span>
<span style="color:var(--accent-color);font-weight:600">${r.token_count || 0} tokenia</span>
<span style="color:${cptColor};font-weight:600">${cpt} merkkiä/token</span>
</div>
<div id="${detailId}" class="token-detail">
<strong style="color:#58a6ff;font-size:12px">(${r.token_count || 0})</strong> ${tokHtml}
</div>`;
chatBox.appendChild(msgDiv);
if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
chatBox.scrollTop = chatBox.scrollHeight;
flashComputing();
} else if (data.type === "pair_task" && selectedTask === 'tokenize') {
chatBox.classList.remove('hidden');
if (chatBox.children.length === 1 && chatBox.children[0].textContent.includes('Odotetaan')) {
chatBox.innerHTML = '';
}
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-msg';
msgDiv.innerHTML = `<span class="chat-prompt">Tokenisoidaan...</span>
<div style="font-size:12px;color:#8b949e">
<div><strong style="color:#58a6ff">EN</strong> "${esc(data.en)}"</div>
<div><strong style="color:#d29922">FI</strong> "${esc(data.fi)}"</div>
</div>`;
chatBox.appendChild(msgDiv);
if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
chatBox.scrollTop = chatBox.scrollHeight;
} else if (data.type === "pair_done") {
chatBox.classList.remove('hidden');
const en = data.en || {};
const fi = data.fi || {};
const overhead = data.overhead_pct || 0;
const nodeId = data.node_id || "?";
const ms = data.duration_ms || 0;
// Päivitetään metriikat
metrics.tasks++;
metrics.totalTokens += (en.token_count || 0) + (fi.token_count || 0);
metrics.totalTimeMs += ms;
updateMetrics();
flashComputing();
// Lokiboksiin yhteenveto
console.log(`EN: ${en.token_count} tokenia (${(en.chars_per_token||0).toFixed(2)} m/t) vs FI: ${fi.token_count} tokenia (${(fi.chars_per_token||0).toFixed(2)} m/t) | ylikustannus: ${overhead}% | ${typeof ms === 'number' ? ms.toFixed(2) : ms}ms`);
const enCpt = parseFloat((en.chars_per_token || 0).toFixed(2));
const fiCpt = parseFloat((fi.chars_per_token || 0).toFixed(2));
// Värit tehokkuudelle
const cptColor = (v) => v >= 4 ? "#3fb950" : v >= 3 ? "#d29922" : "#f85149";
// Ylikustannuksen väri
const ovColor = overhead > 20 ? "#f85149" : overhead > 0 ? "#d29922" : "#3fb950";
// Korvataan viimeisin "Tokenisoidaan..."-viesti, tai luodaan uusi
const lastMsg = chatBox.lastElementChild;
const msgDiv = (lastMsg && lastMsg.querySelector('.chat-prompt')?.textContent === 'Tokenisoidaan...')
? lastMsg : document.createElement('div');
msgDiv.className = 'chat-msg';
// Tokenilistat renderöitäväksi
const renderTokens = (tokens, cls) => (tokens || []).map(t =>
`<span class="tok ${cls}">${esc(t)}</span>`
).join('');
const enTokHtml = renderTokens(en.tokens, 'tok-en');
const fiTokHtml = renderTokens(fi.tokens, 'tok-fi');
const detailId = 'tok-' + Date.now();
msgDiv.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="color:var(--accent-color);font-weight:600;font-size:15px">Solmu #${nodeId}</span>
<div style="display:flex;gap:8px;align-items:center">
<button class="toggle-tokens" onclick="document.getElementById('${detailId}').classList.toggle('visible')">Tokenit</button>
<span style="color:#8b949e;font-size:13px">${typeof ms === 'number' ? ms.toFixed(2) : ms}ms</span>
</div>
</div>
<div style="font-size:14px;display:grid;grid-template-columns:32px 1fr auto auto auto;gap:6px 10px;align-items:baseline">
<strong style="color:#58a6ff">EN</strong>
<span style="color:#79b8ff">"${esc(en.text)}"</span>
<span style="color:#8b949e">${en.char_count} m</span>
<span style="color:var(--accent-color);font-weight:600">${en.token_count} tok</span>
<span style="color:${cptColor(enCpt)};font-weight:600">${enCpt} m/t</span>
<strong style="color:#d29922">FI</strong>
<span style="color:#e3b341">"${esc(fi.text)}"</span>
<span style="color:#8b949e">${fi.char_count} m</span>
<span style="color:var(--accent-color);font-weight:600">${fi.token_count} tok</span>
<span style="color:${cptColor(fiCpt)};font-weight:600">${fiCpt} m/t</span>
</div>
<div id="${detailId}" class="token-detail">
<div style="margin-bottom:6px"><strong style="color:#58a6ff;font-size:12px">EN (${en.token_count})</strong> ${enTokHtml}</div>
<div><strong style="color:#d29922;font-size:12px">FI (${fi.token_count})</strong> ${fiTokHtml}</div>
</div>
<div style="margin-top:10px;display:flex;justify-content:space-between;align-items:baseline;font-size:14px">
<span style="color:#8b949e">(<span style="color:#d29922">${fi.token_count}</span> / <span style="color:#58a6ff">${en.token_count}</span> 1) × 100 = <strong style="color:${ovColor}">${overhead > 0 ? '+' : ''}${overhead}%</strong></span>
<span style="font-size:15px">FI ylikustannus: <strong style="color:${ovColor}">${overhead > 0 ? '+' : ''}${overhead}%</strong></span>
</div>`;
if (!msgDiv.parentNode) chatBox.appendChild(msgDiv);
if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
chatBox.scrollTop = chatBox.scrollHeight;
} else if (data.type === "llm_done") {
// Reititetäänkö agents-näkymään vai codelab-näkymään?
const isAgentsTask = data.task_id && activeStreams[data.task_id];
const isCoder = (data.model || '').includes('Coder');
if (isAgentsTask) {
// Agents-pipeline: päivitetään terminaali
const term = document.getElementById('agent-terminal');
if (term) {
const model = data.model || 'llm';
const tokGen = data.tokens_generated || 0;
const durMs = typeof data.duration_ms === 'number' ? data.duration_ms.toFixed(0) : data.duration_ms || '?';
const tokS = data.tokens_per_sec || '?';
const div = document.createElement('div');
div.className = 'terminal-line';
div.style.color = '#a5d6ff';
div.innerHTML = `${model} <span style="color:#8b949e">${tokGen} tok | ${durMs}ms | ${tokS} tok/s</span>`;
term.appendChild(div);
while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
term.scrollTop = term.scrollHeight;
// Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
}
} else if (isCoder) {
// Codelab: erillinen addCodeResult-handler käsittelee (rivi 2364)
// Poistetaan vain streaming-kortti codelabista
if (codeResults) codeResults.querySelector('.streaming-card')?.remove();
} else {
// Muu malli (network-näkymä): näytetään chatBoxissa
chatBox.querySelector('.streaming-card')?.remove();
chatBox.classList.remove('hidden');
const nodeId = data.node_id || "?";
const model = data.model || "LLM";
const tokGen = data.tokens_generated || 0;
const durMs = data.duration_ms || 0;
const tokS = data.tokens_per_sec || 0;
const loadMs = data.load_time_ms || 0;
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-msg';
msgDiv.style.borderLeftColor = '#a371f7';
msgDiv.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="color:#a371f7;font-weight:600;font-size:15px">Solmu #${nodeId}${model}</span>
<span style="color:#8b949e;font-size:12px">${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms | ${tokS} tok/s</span>
</div>
<div style="font-size:13px;color:#8b949e;margin-bottom:6px">
Prompt: <span style="color:#d29922">"${esc(stripSystemPrompt(data.prompt))}"</span>
</div>
<div style="font-size:14px;color:var(--text-color);line-height:1.5;${(model.includes('Coder') || (data.response||'').includes('def ')) ? 'font-family:Courier New,monospace;background:#010409;padding:10px;border-radius:4px;white-space:pre-wrap;font-size:12px' : ''}">
${data.response ? highlightCode(data.response) : '<em>tyhjä vastaus</em>'}
</div>
<div style="margin-top:8px;font-size:12px;color:#8b949e">
${tokGen} tokenia generoitu | malli ladattu: ${typeof loadMs === 'number' ? loadMs.toFixed(0) : loadMs}ms
</div>`;
chatBox.appendChild(msgDiv);
if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
chatBox.scrollTop = chatBox.scrollHeight;
}
metrics.tasks++;
metrics.totalTokens += (data.tokens_generated || 0);
metrics.totalTimeMs += (data.duration_ms || 0);
flashComputing();
updateMetrics();
console.log(`[${data.model || 'LLM'}] ${data.tokens_generated || 0} tokenia | ${typeof data.duration_ms === 'number' ? data.duration_ms.toFixed(0) : data.duration_ms || '?'}ms | ${data.tokens_per_sec || '?'} tok/s | "${(data.response || '').substring(0, 60)}..."`);
} else if (data.type === "llm_error") {
// Virheenkäsittely: siivotaan streaming-tila
const errMsg = data.error || 'Tuntematon virhe';
if (data.task_id && activeStreams[data.task_id]) {
// Agents-pipeline: näytetään virhe terminaalissa
activeStreams[data.task_id].remove();
delete activeStreams[data.task_id];
}
chatBox.querySelector('.streaming-card')?.remove();
if (codeResults) codeResults.querySelector('.streaming-card')?.remove();
const term = document.getElementById('agent-terminal');
if (term) {
const div = document.createElement('div');
div.className = 'terminal-line';
div.style.color = '#f85149';
div.innerHTML = ` ✗ LLM-virhe: ${errMsg}`;
term.appendChild(div);
term.scrollTop = term.scrollHeight;
}
console.warn('[LLM Error]', errMsg);
} else if (data.type === "llm_chunk") {
// Agents-terminaalin streaming: päivitetään aktiivinen rivi task_id:n perusteella
if (data.task_id && activeStreams[data.task_id]) {
const streamDiv = activeStreams[data.task_id];
const contentEl = streamDiv.querySelector('.stream-content');
if (contentEl) {
contentEl.textContent += data.token || '';
termPanel.scrollTop = termPanel.scrollHeight;
}
// Agents-pipeline omistaa tämän chunkin, ei näytetä muualla
} else {
// Ei agents-task → näytetään streaming-kortti oikeassa näkymässä
const model = data.model || '';
const isCoder = model.includes('Coder');
const targetBox = isCoder ? codeResults : chatBox;
if (targetBox) {
let streamEl = targetBox.querySelector('.streaming-card');
if (!streamEl) {
streamEl = document.createElement('div');
streamEl.className = isCoder ? 'code-task-card streaming-card' : 'chat-msg streaming-card';
streamEl.style.borderLeftColor = '#a371f7';
streamEl.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="color:#a371f7;font-weight:600">${model}</span>
<span class="stream-counter" style="color:var(--accent-color);font-size:12px">0 tok</span>
</div>
<div style="font-size:13px;color:#8b949e;margin-bottom:4px">Prompt: "${esc(stripSystemPrompt(data.prompt))}"</div>
<div class="stream-text" style="font-size:14px;color:var(--text-color);line-height:1.5;${isCoder ? 'font-family:Courier New,monospace;background:#010409;padding:8px;border-radius:4px;white-space:pre-wrap;font-size:12px;color:#3fb950' : ''}"></div>
<div style="margin-top:6px;font-size:11px;color:#d29922">
<span class="spinner" style="display:inline-block;animation:spin 1s linear infinite">&#9696;</span> Generating...
</div>`;
if (isCoder) {
targetBox.insertBefore(streamEl, targetBox.firstChild);
} else {
targetBox.appendChild(streamEl);
}
}
const textEl = streamEl.querySelector('.stream-text');
const counterEl = streamEl.querySelector('.stream-counter');
if (textEl) textEl.textContent += data.token || '';
const tokCount = (textEl.textContent || '').split('').length;
if (counterEl) counterEl.textContent = tokCount + ' tok';
targetBox.scrollTop = targetBox.scrollHeight;
}
}
} else if (data.type === "task_routed") {
const isQueued = data.status === 'queued';
const color = isQueued ? '#d29922' : '#8b949e';
const icon = isQueued ? '⏳' : '→';
const msg = esc(data.message || '');
// Päivitetään olemassaoleva status-rivi (kpnRun luo sen)
const statusDiv = document.getElementById('status-' + data.task_id);
if (statusDiv) {
statusDiv.innerHTML = ` <span style="color:${color}">${icon} ${msg}${isQueued ? '' : ' <span style="animation:blink 1s infinite">▌</span>'}</span>`;
termPanel.scrollTop = termPanel.scrollHeight;
}
// Codelab-loading-teksti
const codeLoading = document.getElementById('code-loading');
if (codeLoading && codeLoading.style.display !== 'none') {
codeLoading.textContent = isQueued
? `${msg}`
: `${msg} — generoidaan...`;
}
} else if (data.type === "llm_prompt") {
// Reagoidaan VAIN agents-pipelinen tehtäviin (task_id + activeStreams)
if (data.task_id && activeStreams[data.task_id]) {
const term = document.getElementById('agent-terminal');
if (term) {
const model = data.model || 'llm';
const promptShort = esc(stripSystemPrompt(data.prompt)).substring(0, 50);
const div = document.createElement('div');
div.className = 'terminal-line';
div.innerHTML = `<span class="terminal-prompt">$</span> kpn run ${model} <span style="color:#8b949e">"${promptShort}"</span>`;
term.appendChild(div);
while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
term.scrollTop = term.scrollHeight;
}
// Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
}
}
} catch(e) {}
};
window._wsMessageHandler = _wsHandler;
if (uiSocket) uiSocket.onmessage = _wsHandler;
btn.addEventListener('click', async () => {
// Käytetään viewer-authissa jo tunnistettua WebGPU-tilaa
let hasWebGPU = detectedWebGPU;
const deviceInfo = {
allocated_gb: 4,
cpu_cores: navigator.hardwareConcurrency || 0,
device_memory_gb: navigator.deviceMemory || 0,
platform: navigator.platform || "",
gpu: detectedGpuInfo,
selected_task: selectedTask
};
const gpuStr = hasWebGPU ? (deviceInfo.gpu?.description || deviceInfo.gpu?.vendor || "WebGPU") : "ei GPU:ta";
// Laskenta käyttää aina CPU:ta (Candle), WebGPU on vain tensorilaskennassa (Burn)
const computeBackend = (selectedTask === 'tokenize')
? (hasWebGPU ? "WebGPU + CPU" : "CPU")
: "CPU (Candle Wasm)";
const vramStr = deviceInfo.gpu?.estimated_vram_gb ? `~${deviceInfo.gpu.estimated_vram_gb} GB` : "?";
const ramNote = deviceInfo.device_memory_gb >= 8 ? "8+ GB (selaimen raja)" : `~${deviceInfo.device_memory_gb} GB`;
// Näytetään laitetiedot paneelissa
const diPanel = document.getElementById('device-info');
diPanel.style.display = 'block';
diPanel.innerHTML = [
`Laskenta: <span>${computeBackend}</span>`,
hasWebGPU ? `GPU: <span>${gpuStr}</span>` : `GPU: <span style="color:#f85149">ei WebGPU:ta</span>`,
hasWebGPU ? `VRAM: <span>${vramStr}</span>` : null,
`CPU: <span>${deviceInfo.cpu_cores} ydintä</span>`,
`RAM: <span>${ramNote}</span>`,
`Varaus: <span>${deviceInfo.allocated_gb} GB</span>`
].filter(Boolean).join(' &middot; ');
// Yhteensopivuusbanneri
const banner = document.getElementById('compat-banner');
banner.style.display = 'block';
if (hasWebGPU) {
banner.className = 'compat-banner gpu';
banner.innerHTML = `WebGPU tunnistettu — ${gpuStr}. Tokenisaatio käyttää GPU:ta, LLM-inferenssi CPU:ta (Candle Wasm).`;
} else {
// Tunnistetaan selain ohjeen personointia varten
const ua = navigator.userAgent;
const isFirefox = ua.includes('Firefox');
const isChrome = ua.includes('Chrome') && !ua.includes('Edg');
const isBrave = ua.includes('Brave') || (navigator.brave && navigator.brave.isBrave);
const isSafari = ua.includes('Safari') && !ua.includes('Chrome');
const isLinux = ua.includes('Linux');
let browserTip = '';
if (isFirefox) {
browserTip = `
<p><strong>Firefox</strong> ei tue WebGPU:ta oletuksena.</p>
<p>Ota käyttöön: <code>about:config</code> → <code>dom.webgpu.enabled</code> = <code>true</code> → käynnistä uudelleen.</p>
<p>Tai vaihda Chromeen/Braveen — niissä WebGPU toimii oletuksena.</p>`;
} else if ((isChrome || isBrave) && isLinux) {
const browser = isBrave ? 'brave-browser' : 'google-chrome';
browserTip = `
<p><strong>${isBrave ? 'Brave' : 'Chrome'} + Linux</strong>: GPU-ajuri ei ehkä tarjoa WebGPU:ta Wayland-ympäristössä.</p>
<p>Kokeile käynnistää selain komentoriviltä:</p>
<code>${browser} --enable-unsafe-webgpu --enable-features=Vulkan --ignore-gpu-blocklist --use-angle=vulkan --ozone-platform=x11</code>`;
} else if (isSafari) {
browserTip = `
<p><strong>Safari</strong>: WebGPU on tuettu versiosta 26 alkaen (macOS Tahoe).</p>
<p>Vanhemmissa versioissa: Develop → Feature Flags → WebGPU.</p>`;
} else {
browserTip = `
<p>Selaimesi ei tue WebGPU:ta. Kokeile <strong>Chrome 113+</strong> tai <strong>Brave</strong>.</p>`;
}
banner.className = 'compat-banner cpu';
banner.innerHTML = `
<details>
<summary>CPU-laskenta (WebGPU ei käytettävissä) — klikkaa ohjeita</summary>
${browserTip}
<p style="margin-top:8px;color:#8b949e;font-size:12px">Laskenta toimii silti CPU:lla, mutta GPU-kiihdytys olisi nopeampi.</p>
</details>`;
}
document.getElementById('initial-state').classList.add('hidden');
document.getElementById('active-state').classList.remove('hidden');
document.getElementById('user-input-box').classList.remove('hidden');
btn.style.display = 'none';
// Nappin teksti ja placeholder tehtävän mukaan
const sendBtnEl = document.getElementById('send-btn');
const placeholderEl = document.getElementById('user-text');
const t = window.currentLangDict || translations.fi;
if (selectedTask === 'tokenize') {
sendBtnEl.textContent = t.btn_tokenize || 'Tokenisoi';
} else if (selectedTask === 'qwen-coder') {
sendBtnEl.textContent = 'Koodaa';
} else {
sendBtnEl.textContent = 'Generoi';
}
try {
if (!wasmInitialized) {
console.log("Ladataan Burn Wasm -binääriä...");
await init();
wasmInitialized = true;
}
window.wasm_active = true;
metrics.startTime = Date.now();
// Asetetaan Connected-tila (keltainen) — vihreäksi vasta kun laskentaa tapahtuu
const nodeStatusEl = document.getElementById('node-status');
nodeStatusEl.textContent = 'Connected';
nodeStatusEl.style.color = '#d29922';
// Varmistetaan, että Wasm saa nykyisen sliderin arvon heti kärkeen
set_gpu_load(parseInt(loadSlider.value));
// WebAssembly yhdistää oikeaksi Agent Nodeksi
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
const taskIds = {'tokenize': 0, 'smollm-135m': 1, 'qwen-05b': 2, 'phi3-mini': 3, 'qwen-coder-05b': 4, 'qwen-coder-3b': 5};
const taskId = taskIds[selectedTask] || 0;
await start_agent_node(wsUrl, hasWebGPU, JSON.stringify(deviceInfo), taskId);
} catch(e) {
console.log("Virhe GPU-käynnistyksessä: " + e);
}
});
// === Koodilaboratorio ===
const codeInput = document.getElementById('code-input');
const codeSendBtn = document.getElementById('code-send-btn');
const codeResults = document.getElementById('code-results');
const codeLoading = document.getElementById('code-loading');
let coderWsReady = false;
let coderWs = null; // Erillinen WS coder-nodelle
let pendingCodePrompt = null;
// Yksinkertainen Python-syntaksikorostus
function highlightCode(code) {
if (typeof hljs !== 'undefined') {
try {
const result = hljs.highlightAuto(code);
return result.value;
} catch(e) {}
}
return esc(code);
}
function addCodeResult(data) {
// Poistetaan streaming-kortti
codeResults.querySelector('.streaming-card')?.remove();
const model = data.model || 'Coder';
const tokGen = data.tokens_generated || 0;
const durMs = data.duration_ms || 0;
const tokS = data.tokens_per_sec || 0;
const response = esc(data.response);
codeMetrics.tasks++;
codeMetrics.tokens += tokGen;
codeMetrics.lastSpeed = tokS;
document.getElementById('code-m-tasks').textContent = codeMetrics.tasks;
document.getElementById('code-m-tokens').textContent = codeMetrics.tokens.toLocaleString('fi-FI');
document.getElementById('code-m-speed').textContent = tokS + ' tok/s';
if (codeResults.querySelector('[data-placeholder]')) {
codeResults.innerHTML = '';
}
codeLoading.style.display = 'none';
codeSendBtn.disabled = false;
codeSendBtn.textContent = 'Generate';
document.getElementById('coder-status').textContent = 'Connected';
document.getElementById('coder-status').style.color = '#d29922';
const card = document.createElement('div');
card.className = 'code-task-card';
card.innerHTML = `
<div class="prompt">${esc(stripSystemPrompt(data.prompt))}</div>
<div class="code-output">${highlightCode(response)}</div>
<div class="meta">
${model} · ${tokGen} tokenia · ${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms · ${tokS} tok/s
</div>`;
codeResults.insertBefore(card, codeResults.firstChild);
if (codeResults.children.length > 10) codeResults.removeChild(codeResults.lastChild);
}
// Kuuntele coder-tuloksia UI WebSocketista (vain codelab-tehtävät)
uiSocket.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'llm_done' && (data.model || '').includes('Coder')) {
// Agents-pipeline asettaa aina task_id:n, codelabin user_text-polku ei koskaan
if (data.task_id) return;
addCodeResult(data);
}
} catch(e) {}
});
// Pipeline-vaiheiden päivitys
function setStep(id, state, extra) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'code-step ' + state;
const icon = el.querySelector('.step-icon');
if (state === 'active') icon.textContent = '\u25F7'; // spinning
else if (state === 'done') icon.textContent = '\u2713';
else if (state === 'error') icon.textContent = '\u2717';
if (extra) {
const pct = document.getElementById(id + '-pct');
if (pct) pct.textContent = extra;
}
}
// Kuuntele console.log-viestejä pipeline-vaiheiden seuraamiseksi
// Terminaalin lataustilarivi — päivittyy dynaamisesti
function termLoadStatus(phase, detail) {
const term = document.getElementById('agent-terminal');
if (!term) return;
let statusLine = term.querySelector('.term-load-status');
if (!statusLine) {
statusLine = document.createElement('div');
statusLine.className = 'terminal-line term-load-status';
term.appendChild(statusLine);
}
const spinner = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
const frame = spinner[Math.floor(Date.now() / 100) % spinner.length];
statusLine.innerHTML = ` <span style="color:#d29922">${frame}</span> <span style="color:#8b949e">${phase}</span>${detail ? ` <span style="color:#58a6ff">${detail}</span>` : ''}`;
term.scrollTop = term.scrollHeight;
}
function termLoadDone() {
const term = document.getElementById('agent-terminal');
if (!term) return;
const statusLine = term.querySelector('.term-load-status');
if (statusLine) statusLine.remove();
}
const origCodeLog = console.log;
const codeLogListener = (...args) => {
const msg = args.join(' ');
if (msg.includes('[Coder]') || msg.includes('[Storage]') || msg.includes('Burn Wasm') || msg.includes('Kipinä Agent Node')) {
// Terminaalin lataustilapäivitys
if (msg.includes('Agent Node käynnistyy')) termLoadStatus('WASM alustettu');
if (msg.includes('Ladataan') && msg.includes('tokenizer')) termLoadStatus('Ladataan tokenizer...');
if (msg.includes('tokenizer') && (msg.includes('löytyi') || msg.includes('tallennettu'))) termLoadStatus('Tokenizer ✓');
if (msg.includes('Ladataan') && msg.includes('gguf')) termLoadStatus('Ladataan mallia...');
const dlMatch = msg.match(/lataus: (\d+)%/);
if (dlMatch) termLoadStatus('Ladataan mallia...', dlMatch[1] + '%');
if (msg.includes('tallennettu') && msg.includes('gguf')) termLoadStatus('Malli tallennettu');
if (msg.includes('Rakennetaan')) termLoadStatus('Rakennetaan mallia...');
if (msg.includes('Malli ladattu')) termLoadDone();
if (msg.includes('Burn Wasm')) setStep('step-wasm', 'active');
if (msg.includes('Agent Node käynnistyy')) { setStep('step-wasm', 'done'); }
// Tokenizer: [Coder] tai [Storage] -prefiksi
if (msg.includes('Tokenizer') && msg.includes('löytyi')) { setStep('step-tokenizer', 'done'); }
if (msg.includes('tokenizer') && msg.includes('löytyi')) { setStep('step-tokenizer', 'done'); }
if ((msg.includes('[Coder]') || msg.includes('[Storage]')) && msg.includes('Ladataan') && msg.includes('tokenizer')) { setStep('step-tokenizer', 'active'); }
if ((msg.includes('[Coder]') || msg.includes('[Storage]')) && msg.includes('tokenizer') && msg.includes('tallennettu')) { setStep('step-tokenizer', 'done'); }
if (msg.includes('[Coder]') && msg.includes('model') && msg.includes('lataus:')) {
setStep('step-model', 'active');
const match = msg.match(/lataus: (\d+)%/);
if (match) setStep('step-model', 'active', match[1] + '%');
}
if (msg.includes('[Coder]') && msg.includes('model') && msg.includes('löytyi')) { setStep('step-model', 'done', 'cache'); }
if (msg.includes('[Coder]') && msg.includes('model') && msg.includes('tallennettu')) { setStep('step-model', 'done', '100%'); }
if (msg.includes('[Coder]') && msg.includes('Rakennetaan')) { setStep('step-build', 'active'); }
if (msg.includes('Agent Node käynnistyy') || msg.includes('Rakennetaan')) {
const cd = document.getElementById('agent-compute-dot');
const cl = document.getElementById('agent-compute-label');
const btn = document.getElementById('agent-compute-btn');
if (cd) cd.style.background = '#d29922';
if (cl) { cl.textContent = 'Ladataan...'; cl.style.color = '#d29922'; }
if (btn && btn.dataset.state !== 'ready') {
btn.dataset.state = 'loading';
btn.textContent = 'Peruuta';
btn.style.borderColor = '#f85149';
btn.style.color = '#f85149';
}
}
if (msg.includes('[Coder]') && msg.includes('Malli ladattu')) {
// Malli on valmis — merkataan kaikki vaiheet valmiiksi
setStep('step-wasm', 'done');
setStep('step-tokenizer', 'done');
const pctSpan = document.getElementById('step-model-pct');
if (pctSpan && pctSpan.textContent.includes('100%')) {
setStep('step-model', 'done', '100%');
} else {
setStep('step-model', 'done', 'cache');
}
setStep('step-build', 'done');
setStep('step-ready', 'done');
// Agents-sivun compute-status: valmis
const cd = document.getElementById('agent-compute-dot');
const cl = document.getElementById('agent-compute-label');
const btn = document.getElementById('agent-compute-btn');
if (cd) cd.style.background = '#3fb950';
const sizeLabel = coderSize === '3b' ? '3B (3 miljardia parametria)' : '0.5B (500 miljoonaa parametria)';
if (cl) { cl.textContent = 'Qwen2.5-Coder:' + (coderSize === '3b' ? '3B' : '0.5B'); cl.style.color = '#3fb950'; cl.title = sizeLabel + ' · Candle Wasm · CPU · max 512 tok'; }
if (btn) { btn.dataset.state = 'ready'; btn.textContent = '✓ Valmis'; btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950'; btn.style.cursor = 'default'; btn.title = 'Kielimalli ladattu — oma kone on valmis laskentaan'; }
localStorage.setItem('kpn-coder-loaded', 'true');
// Terminaaliin valmis-viesti (vain kerran)
if (!window._coderReadyLogged) {
window._coderReadyLogged = true;
const term = document.getElementById('agent-terminal');
if (term) {
const sLabel = coderSize === '3b' ? 'Qwen2.5-Coder:1.5B Q4' : 'Qwen2.5-Coder:0.5B';
termLog(` <span style="color:#3fb950">✓</span> ${sLabel} valmis — kpn run coder "prompti"`, '#3fb950');
}
}
}
if (msg.includes('[Coder]') && msg.includes('Syöte:')) {
// Pipeline piiloon kun generointi alkaa
setTimeout(() => { document.getElementById('code-pipeline').style.display = 'none'; }, 1000);
}
}
};
// Lisätään kuuntelija alkuperäisen console.log ylikirjoituksen päälle
const _prevConsoleLog = console.log;
console.log = function(...args) { _prevConsoleLog.apply(console, args); codeLogListener(...args); };
// Web Worker -pohjainen laskentasolmu — UI ei jäädy inferenssin aikana
let coderWorker = null;
async function ensureCoderNode() {
if (coderJoined) return;
coderJoined = true;
document.getElementById('coder-status').textContent = 'Käynnistyy...';
document.getElementById('coder-status').style.color = '#d29922';
document.getElementById('code-pipeline').style.display = 'block';
setStep('step-wasm', 'active');
try {
// Käynnistetään WASM Web Workerissa
coderWorker = new Worker('./worker.js', { type: 'module' });
// Workerin console.log-viestit → pääsäikeen kuuntelija
// Worker ei voi kutsua console.log näkyvästi, joten WASM:n console_log
// ei näy automaattisesti. Workerissa console.log menee Workerin konsoliin.
await new Promise((resolve, reject) => {
coderWorker.onmessage = (e) => {
if (e.data.type === 'ready') resolve();
else if (e.data.type === 'error') reject(new Error(e.data.message));
};
coderWorker.postMessage({ type: 'init' });
});
setStep('step-wasm', 'done');
setStep('step-tokenizer', 'active');
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
const deviceInfo = {
allocated_gb: 4,
cpu_cores: navigator.hardwareConcurrency || 0,
device_memory_gb: navigator.deviceMemory || 0,
platform: navigator.platform || "",
gpu: null,
selected_task: coderSize === '3b' ? 'qwen-coder-3b' : 'qwen-coder-05b'
};
const taskId = coderSize === '3b' ? 5 : 4;
// Käynnistetään node Workerissa
coderWorker.onmessage = (e) => {
if (e.data.type === 'started') {
document.getElementById('coder-status').textContent = 'Connected';
document.getElementById('coder-status').style.color = '#d29922';
coderWsReady = true;
} else if (e.data.type === 'log') {
// Workerin console.log → pääsäikeen kuuntelijat (tilaindikaattori, pipeline-stepit)
console.log(e.data.message);
} else if (e.data.type === 'error') {
console.log('[Worker] Virhe: ' + e.data.message);
}
};
coderWorker.postMessage({
type: 'start',
data: { hubUrl: wsUrl, hasWebGPU: false, deviceInfo: JSON.stringify(deviceInfo), taskId }
});
// Warmup
setTimeout(() => {
if (uiSocket && uiSocket.readyState === 1) {
uiSocket.send(JSON.stringify({
type: 'user_text',
text: '{"prompt":"warmup","max_tokens":1}',
task_type: 'qwen-coder'
}));
}
}, 500);
if (pendingCodePrompt) {
setTimeout(() => {
sendCodeToHub(pendingCodePrompt);
}, 2000);
pendingCodePrompt = null;
}
} catch(e) {
console.log("Coder-virhe: " + e);
document.getElementById('coder-status').textContent = 'Virhe';
document.getElementById('coder-status').style.color = '#f85149';
coderJoined = false;
}
}
// Mallia EI ladata automaattisesti — käyttäjä käynnistää itse: kpn load
// Laskentasolmun käynnistys/pysäytys -nappi
let computeAbortController = null;
document.getElementById('agent-compute-btn')?.addEventListener('click', () => {
const btn = document.getElementById('agent-compute-btn');
const cl = document.getElementById('agent-compute-label');
if (!btn) return;
if (btn.dataset.state === 'ready') return; // Jo valmis, ei tehdä mitään
if (btn.dataset.state === 'loading') {
// Cancel — ladataan sivua uudelleen koska Wasm-latausta ei voi pysäyttää
btn.textContent = 'Peruutetaan...';
btn.disabled = true;
window.location.reload();
return;
}
// Käynnistetään
btn.dataset.state = 'loading';
btn.textContent = 'Peruuta';
btn.style.borderColor = '#f85149';
btn.style.color = '#f85149';
btn.title = 'Peruuta kielimallin lataus';
ensureCoderNode();
});
// JSON mode toggle
const jsonToggle = document.getElementById('json-mode-toggle');
const jsonHelp = document.getElementById('json-help');
const textInput = document.getElementById('code-input');
const jsonInput = document.getElementById('code-input-json');
jsonToggle?.addEventListener('change', () => {
if (jsonToggle.checked) {
textInput.style.display = 'none';
jsonInput.style.display = 'block';
jsonHelp.style.display = 'block';
} else {
textInput.style.display = 'block';
jsonInput.style.display = 'none';
jsonHelp.style.display = 'none';
}
});
function sendCodeToHub(text) {
if (uiSocket && uiSocket.readyState === 1) {
uiSocket.send(JSON.stringify({ type: 'user_text', text: text, task_type: 'qwen-coder' }));
}
}
async function handleCodeSubmit() {
let promptText;
if (jsonToggle.checked) {
// JSON mode
const raw = jsonInput.value.trim();
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (!parsed.prompt) { alert('JSON must contain "prompt" field'); return; }
// Lähetetään koko JSON hubille — node lukee promptin ja parametrit
promptText = raw;
} catch(e) {
alert('Invalid JSON: ' + e.message);
return;
}
} else {
// Text mode
promptText = textInput.value.trim();
if (!promptText) return;
textInput.value = '';
}
codeSendBtn.disabled = true;
codeSendBtn.textContent = 'Generating...';
codeLoading.style.display = 'block';
if (!coderJoined) {
pendingCodePrompt = promptText;
const dlSize = coderSize === '3b' ? '~6.2 GB' : '~990 MB';
codeLoading.textContent = `Loading Qwen2.5-Coder:${coderSize === '3b' ? '3B' : '0.5B'} (${dlSize} on first run)...`;
await ensureCoderNode();
} else {
codeLoading.textContent = 'Generating code...';
document.getElementById('coder-status').textContent = 'Computing';
document.getElementById('coder-status').style.color = 'var(--success-color)';
sendCodeToHub(promptText);
}
}
codeSendBtn?.addEventListener('click', handleCodeSubmit);
textInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleCodeSubmit(); });
const translations = {
fi: {
main_title: "<span style=\"color:#ff6b00\">Kipinä</span> <span>Agentic Playground</span>",
main_subtitle: "AI-ohjelmistokehitystiimi",
tab_network: "Laskentaverkko",
tab_codelab: "Koodilaboratorio",
tab_agents: "Kipinä Agentic Playground",
stat_nodes_lbl: "Aktiivisia Nodeja",
stat_tasks_lbl: "Verkossa Suoritettua Tehtävää (Globaali)",
stat_vram_lbl: "Verkon yhteis-VRAM",
btn_select_all: "Valitse kaikki",
btn_clear_all: "Tyhjennä valinnat",
task_title: "Valitse tehtävä",
btn_join: "Liity laskentaverkkoon",
btn_disconnect: "Katkaise Yhteys",
resource_mgmt: "Resurssien hallinta",
power_limiter: "Laskentatehon rajoitin",
auto_tasks: "Vastaanota automaattisia tehtäviä hubilta",
try_own_text: "Kokeile omaa tekstiä:",
btn_tokenize: "Tokenisoi",
btn_code: "Koodaa",
btn_generate: "Generoi",
metric_tasks: "Tehtäviä",
metric_avg: "Ka. aika",
metric_tokens: "Tokeneita",
metric_uptime: "Käynnissä"
},
se: {
main_title: "<span style=\"color:#ff6b00\">Kipinä</span> <span>Agentic Playground</span>",
main_subtitle: "AI-programvaruutvecklingsteam",
tab_network: "Kalkylnätverk",
tab_codelab: "Kodlaboratorium",
tab_agents: "Kipinä Agentic Playground",
stat_nodes_lbl: "Aktiva Noder",
stat_tasks_lbl: "Slutförda Uppgifter (Globalt)",
stat_vram_lbl: "Nätverkets totala VRAM",
btn_select_all: "Välj alla",
btn_clear_all: "Rensa val",
task_title: "Välj uppgift",
btn_join: "Gå med i nätverket",
btn_disconnect: "Koppla från",
resource_mgmt: "Resurshantering",
power_limiter: "Beräkningskraftsbegränsare",
auto_tasks: "Ta emot automatiska uppgifter från hubben",
try_own_text: "Prova med egen text:",
btn_tokenize: "Tokenisera",
btn_code: "Koda",
btn_generate: "Generera",
metric_tasks: "Uppgifter",
metric_avg: "Snittid",
metric_tokens: "Tokens",
metric_uptime: "Drifttid"
},
en: {
main_title: "<span style=\"color:#ff6b00\">Kipinä</span> <span>Agentic Playground</span>",
main_subtitle: "AI Software Development Team",
tab_network: "Compute Network",
tab_codelab: "Code Laboratory",
tab_agents: "Kipinä Agentic Playground",
stat_nodes_lbl: "Active Nodes",
stat_tasks_lbl: "Tasks Completed (Global)",
stat_vram_lbl: "Total Network VRAM",
btn_select_all: "Select all",
btn_clear_all: "Clear selection",
task_title: "Choose task",
btn_join: "Join Compute Network",
btn_disconnect: "Disconnect",
resource_mgmt: "Resource Management",
power_limiter: "Compute Power Limiter",
auto_tasks: "Receive automatic tasks from hub",
try_own_text: "Test your own text:",
btn_tokenize: "Tokenize",
btn_code: "Code",
btn_generate: "Generate",
metric_tasks: "Tasks",
metric_avg: "Avg. Time",
metric_tokens: "Tokens",
metric_uptime: "Uptime"
}
};
window.setLanguage = function(lang) {
localStorage.setItem('kpn_lang', lang);
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
const btn = document.querySelector(`.lang-btn[data-lang="${lang}"]`);
if (btn) btn.classList.add('active');
const t = translations[lang] || translations.fi;
window.currentLangDict = t;
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (t[key]) {
if(t[key].includes('<')) el.innerHTML = t[key];
else el.textContent = t[key];
}
});
if(window.updatePromptEditor) window.updatePromptEditor();
// Käännä lennossa ne painikkeet jotka ovat ehkä vaihtaneet tekstiä dynaamisesti (esim. JS-tilan muutokset)
const sendBtnEl = document.getElementById('send-btn');
if (sendBtnEl && window.wasm_active) {
// Riippuu valitusta tehtävästä
const sTask = window.selectedTask || document.querySelector('.task-option.selected')?.dataset?.task;
if (sTask === 'tokenize') sendBtnEl.textContent = t.btn_tokenize || 'Tokenisoi';
else if (sTask === 'qwen-coder') sendBtnEl.textContent = t.btn_code || 'Koodaa';
else sendBtnEl.textContent = t.btn_generate || 'Generoi';
}
const jbtn = document.getElementById('start-btn');
if (jbtn) {
// start-btn vaihtuu connect / disconnect kun ollaan aktiivitilassa
if (window.wasm_active || jbtn.textContent === 'Katkaise Yhteys' || jbtn.textContent === 'Koppla från' || jbtn.textContent === 'Disconnect') {
jbtn.textContent = t.btn_disconnect || 'Katkaise Yhteys';
} else {
jbtn.textContent = t.btn_join || 'Liity laskentaverkkoon';
}
}
const cbtn = document.getElementById('code-send-btn');
if (cbtn && !cbtn.textContent.includes('...')) {
cbtn.textContent = t.btn_generate || 'Generate';
}
};
document.addEventListener('DOMContentLoaded', () => {
const savedLang = localStorage.getItem('kpn_lang') || 'fi';
setLanguage(savedLang);
// Valitaan Asiakas-agentti automaattisesti sivun ladattua (muttei jatkossa)
setTimeout(() => {
if (window.selectAgent) window.selectAgent('client');
}, 100);
});
// ── Agent Builder ──
const BUILDER_AVATARS = [
'/avatars/kipina_notext.png', '/avatars/karhunpentu.png', '/avatars/kettu_notext.png',
'/avatars/pesukarhu_notext.png', '/avatars/susi_notext.png', '/avatars/laiskiainen_notext.png',
'/avatars/aikuinen_susi.png', '/avatars/gecko_notext.png',
'/avatars/bear.png', '/avatars/beaver.png', '/avatars/chameleon.png',
'/avatars/elephant.png', '/avatars/gecko.png', '/avatars/lion.png',
'/avatars/mantis.png', '/avatars/owl.png', '/avatars/penguin.png',
'/avatars/serpent.png', '/avatars/spider.png', '/avatars/tortoise.png',
'/avatars/walrus.png'
];
let builderAgents = [];
let builderEditing = null; // null = uusi, string = id
async function builderLoad() {
try {
const res = await fetch('/api/v1/agents');
if (res.ok) builderAgents = await res.json();
} catch(e) {}
builderRenderList();
}
function builderRenderList() {
const list = document.getElementById('builder-agent-list');
if (!list) return;
list.innerHTML = builderAgents.map(a => `
<div onclick="builderEdit('${a.id}')" style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:12px;cursor:pointer;transition:border-color 0.2s;display:flex;align-items:center;gap:10px" onmouseover="this.style.borderColor='${a.color}'" onmouseout="this.style.borderColor='#30363d'">
<img src="${a.avatar}" style="width:40px;height:40px;border-radius:10px;border:2px solid ${a.color};object-fit:cover">
<div>
<div style="font-weight:600;color:${a.color};font-size:14px">${esc(a.name)}</div>
<div style="color:#8b949e;font-size:11px">${esc(a.model)} · ${a.role}</div>
</div>
</div>
`).join('');
}
function builderNew() {
builderEditing = null;
document.getElementById('builder-form').style.display = 'block';
document.getElementById('builder-new-btn').style.display = 'none';
document.getElementById('builder-delete-btn').style.display = 'none';
document.getElementById('builder-id').value = '';
document.getElementById('builder-id').disabled = false;
document.getElementById('builder-name').value = '';
document.getElementById('builder-role').value = 'coder';
document.getElementById('builder-model').value = 'qwen2.5-coder:7b';
document.getElementById('builder-color').value = '#3fb950';
document.getElementById('builder-docs').value = '';
document.getElementById('builder-prompt').value = '';
document.getElementById('builder-temp').value = '0.7';
document.getElementById('builder-topk').value = '40';
document.getElementById('builder-maxtokens').value = '512';
document.getElementById('builder-avatar-preview').src = '/avatars/kipina_notext.png';
builderRenderAvatarSelect();
}
function builderEdit(id) {
const a = builderAgents.find(x => x.id === id);
if (!a) return;
builderEditing = id;
document.getElementById('builder-form').style.display = 'block';
document.getElementById('builder-new-btn').style.display = 'none';
document.getElementById('builder-delete-btn').style.display = a.is_default ? 'none' : 'inline-block';
document.getElementById('builder-id').value = a.id;
document.getElementById('builder-id').disabled = true;
document.getElementById('builder-name').value = a.name;
document.getElementById('builder-role').value = a.role;
document.getElementById('builder-model').value = a.model;
document.getElementById('builder-color').value = a.color;
document.getElementById('builder-docs').value = a.docs || '';
document.getElementById('builder-prompt').value = a.prompt;
document.getElementById('builder-temp').value = a.temperature;
document.getElementById('builder-topk').value = a.top_k;
document.getElementById('builder-maxtokens').value = a.max_tokens;
document.getElementById('builder-avatar-preview').src = a.avatar;
builderRenderAvatarSelect();
}
function builderRenderAvatarSelect() {
const container = document.getElementById('builder-avatar-select');
container.innerHTML = BUILDER_AVATARS.map(src =>
`<img src="${src}" style="width:36px;height:36px;border-radius:8px;border:2px solid #30363d;cursor:pointer;object-fit:cover" onclick="document.getElementById('builder-avatar-preview').src='${src}';document.getElementById('builder-avatar-select').style.display='none'">`
).join('');
}
async function builderSave() {
const payload = {
id: document.getElementById('builder-id').value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''),
name: document.getElementById('builder-name').value.trim(),
avatar: document.getElementById('builder-avatar-preview').src.replace(location.origin, ''),
role: document.getElementById('builder-role').value,
model: document.getElementById('builder-model').value.trim(),
color: document.getElementById('builder-color').value,
docs: document.getElementById('builder-docs').value.trim() || null,
prompt: document.getElementById('builder-prompt').value,
temperature: parseFloat(document.getElementById('builder-temp').value) || 0.7,
top_k: parseInt(document.getElementById('builder-topk').value) || 40,
max_tokens: parseInt(document.getElementById('builder-maxtokens').value) || 512,
repetition_penalty: 1.15,
};
if (!payload.id || !payload.name) { alert('Tunniste ja nimi vaaditaan'); return; }
try {
const res = await fetch('/api/v1/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
builderCancel();
await builderLoad();
} else {
alert('Virhe: ' + await res.text());
}
} catch(e) { alert('Virhe: ' + e.message); }
}
async function builderDelete() {
if (!builderEditing) return;
if (!confirm('Poistetaanko agentti "' + builderEditing + '"?')) return;
try {
const res = await fetch('/api/v1/agents/' + builderEditing, { method: 'DELETE' });
if (res.ok) {
builderCancel();
await builderLoad();
} else {
alert('Virhe: ' + await res.text());
}
} catch(e) { alert('Virhe: ' + e.message); }
}
function builderCancel() {
document.getElementById('builder-form').style.display = 'none';
document.getElementById('builder-new-btn').style.display = 'inline-block';
builderEditing = null;
}
function builderExportCrewAI() {
if (builderAgents.length === 0) { alert('Ei agentteja — luo ensin agentteja.'); return; }
// Rooli-kuvaukset CrewAI:lle
const roleGoals = {
coder: 'Write clean, tested, production-ready code',
qa: 'Find bugs, write tests, ensure quality',
devops: 'Create deployment configs, CI/CD, Docker',
devsecops: 'Security auditing, OWASP compliance, vulnerability scanning',
architect: 'System design, API contracts, architecture decisions',
iac: 'Write infrastructure-as-code (OpenTofu/Terraform HCL)',
data: 'Database design, SQL optimization, data modeling',
manager: 'Break down tasks, coordinate team, prioritize work',
writer: 'Write documentation, READMEs, guides',
custom: 'Complete assigned tasks effectively',
};
// crew.py
const agentDefs = builderAgents.map(a => {
const varName = a.id.replace(/[^a-z0-9_]/g, '_');
const goal = roleGoals[a.role] || roleGoals.custom;
return `${varName} = Agent(
role="${a.name}",
goal="${goal}",
backstory="""${a.prompt.replace(/"/g, '\\"')}""",
llm="ollama/${a.model}",
verbose=True,
)`;
}).join('\n\n');
const taskDefs = builderAgents.map((a, i) => {
const varName = a.id.replace(/[^a-z0-9_]/g, '_');
const goal = roleGoals[a.role] || roleGoals.custom;
return `task_${varName} = Task(
description="${goal} for the project: {project_description}",
expected_output="Completed deliverable from ${a.name}",
agent=${varName},
)`;
}).join('\n\n');
const agentVars = builderAgents.map(a => a.id.replace(/[^a-z0-9_]/g, '_'));
const taskVars = builderAgents.map(a => 'task_' + a.id.replace(/[^a-z0-9_]/g, '_'));
const crewPy = `"""
Kipinä Agent Crew — generoitu Agent Builderista
https://kipina.studio/#builder
"""
from crewai import Agent, Task, Crew, Process
# ── Agentit ──
${agentDefs}
# ── Tehtävät ──
${taskDefs}
# ── Tiimi ──
crew = Crew(
agents=[${agentVars.join(', ')}],
tasks=[${taskVars.join(', ')}],
process=Process.sequential,
verbose=True,
)
if __name__ == "__main__":
import sys
project = sys.argv[1] if len(sys.argv) > 1 else "FastAPI + SQLite CRUD API"
result = crew.kickoff(inputs={"project_description": project})
print(result)
`;
// pyproject.toml
const pyproject = `[project]
name = "kipina-crew"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"crewai[tools]>=0.100.0",
]
[project.scripts]
crew = "crew:crew.kickoff"
`;
// .env
const models = [...new Set(builderAgents.map(a => a.model))];
const dotenv = `# Ollama-asetukset
OLLAMA_BASE_URL=http://ollama:11434
# Mallit jotka tarvitaan: ${models.join(', ')}
`;
// Dockerfile
const dockerfile = `FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml ./
RUN uv sync --no-dev
COPY crew.py .env ./
CMD ["uv", "run", "python", "crew.py"]
`;
// docker-compose.yml
const ollamaPulls = models.map(m => `ollama pull ${m}`).join(' && ');
const compose = `services:
ollama:
image: ollama/ollama:latest
container_name: kipina-crew-ollama
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/version"]
interval: 5s
timeout: 3s
retries: 10
# Ladataan mallit Ollamaan ennen crewn käynnistystä
ollama-init:
image: ollama/ollama:latest
depends_on:
ollama:
condition: service_healthy
entrypoint: ["sh", "-c", "${ollamaPulls}"]
environment:
- OLLAMA_HOST=http://ollama:11434
crew:
build: .
container_name: kipina-crew
depends_on:
ollama-init:
condition: service_completed_successfully
environment:
- OLLAMA_BASE_URL=http://ollama:11434
# Projektin kuvaus argumenttina:
# docker compose run crew uv run python crew.py "FastAPI todo app"
volumes:
ollama-models:
`;
// README.md
const readme = `# Kipinä Agent Crew
Generoitu [Kipinä Agent Builderista](https://kipina.studio/#builder).
## Agentit
${builderAgents.map(a => `- **${a.name}** — ${a.role} (${a.model})`).join('\\n')}
## Käynnistys
\`\`\`bash
# Docker Compose (suositeltu) — käynnistää Ollaman + lataa mallit + ajaa crewn
docker compose run crew uv run python crew.py "FastAPI + SQLite CRUD API"
# Tai lokaalisti (vaatii Ollaman käynnissä)
uv sync
uv run python crew.py "FastAPI + SQLite CRUD API"
\`\`\`
`;
// Genero ZIP käyttäen samaa logiikkaa kuin downloadZip
const files = {
'crew.py': crewPy,
'pyproject.toml': pyproject,
'.env': dotenv,
'Dockerfile': dockerfile,
'docker-compose.yml': compose,
'README.md': readme,
};
function crc32(bytes) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc ^= bytes[i];
for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
const entries = Object.entries(files);
const parts = [];
const centralDir = [];
let offset = 0;
for (const [name, content] of entries) {
const nameBytes = new TextEncoder().encode(name);
const contentBytes = new TextEncoder().encode(content || '');
const crc = crc32(contentBytes);
const header = new Uint8Array(30 + nameBytes.length);
const hv = new DataView(header.buffer);
hv.setUint32(0, 0x04034b50, true);
hv.setUint16(4, 20, true);
hv.setUint16(8, 0, true);
hv.setUint32(14, crc, true);
hv.setUint32(18, contentBytes.length, true);
hv.setUint32(22, contentBytes.length, true);
hv.setUint16(26, nameBytes.length, true);
header.set(nameBytes, 30);
parts.push(header, contentBytes);
const cd = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(cd.buffer);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 20, true);
cv.setUint16(6, 20, true);
cv.setUint16(10, 0, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, contentBytes.length, true);
cv.setUint32(24, contentBytes.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true);
cd.set(nameBytes, 46);
centralDir.push(cd);
offset += header.length + contentBytes.length;
}
const cdOffset = offset;
let cdSize = 0;
for (const cd of centralDir) { parts.push(cd); cdSize += cd.length; }
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, cdSize, true);
ev.setUint32(16, cdOffset, true);
parts.push(eocd);
const blob = new Blob(parts, { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'kipina-crew.zip';
a.click();
URL.revokeObjectURL(url);
}
// Globaalit Builder-funktiot (onclick tarvitsee)
window.builderNew = builderNew;
window.builderEdit = builderEdit;
window.builderSave = builderSave;
window.builderDelete = builderDelete;
window.builderExportCrewAI = builderExportCrewAI;
window.builderCancel = builderCancel;
// ── Hahmogalleria ──
(function renderGallery() {
const ALL_AVATARS = [
{ file: 'kipina_notext.png', name: 'Salamanteri', role: 'Koodari' },
{ file: 'karhunpentu.png', name: 'Karhunpentu', role: 'Manageri' },
{ file: 'kettu_notext.png', name: 'Kettu', role: 'Asiakas' },
{ file: 'pesukarhu_notext.png', name: 'Pesukarhu', role: 'Data' },
{ file: 'susi_notext.png', name: 'Pikkususi', role: 'QA' },
{ file: 'laiskiainen_notext.png', name: 'Laiskiainen', role: 'DevOps' },
{ file: 'aikuinen_susi.png', name: 'Aikuinen susi', role: 'Tarkkailija' },
{ file: 'gecko_notext.png', name: 'Gecko', role: 'Tofuist / IaC' },
{ file: 'bear.png', name: 'Karhu', role: '' },
{ file: 'beaver.png', name: 'Majava', role: '' },
{ file: 'chameleon.png', name: 'Kameleontti', role: '' },
{ file: 'elephant.png', name: 'Norsu', role: '' },
{ file: 'gecko.png', name: 'Gecko v2', role: '' },
{ file: 'lion.png', name: 'Leijona', role: '' },
{ file: 'mantis.png', name: 'Rukoilijasirkka', role: '' },
{ file: 'owl.png', name: 'Pöllö', role: '' },
{ file: 'penguin.png', name: 'Pingviini', role: '' },
{ file: 'serpent.png', name: 'Kyy', role: 'DevSecOps' },
{ file: 'spider.png', name: 'Hämähäkki', role: '' },
{ file: 'tortoise.png', name: 'Kilpikonna', role: '' },
{ file: 'walrus.png', name: 'Mursu', role: '' },
];
const grid = document.getElementById('gallery-grid');
if (!grid) return;
grid.innerHTML = ALL_AVATARS.map(a => {
const path = '/avatars/' + a.file;
return `<div style="background:#161b22;border:1px solid #30363d;border-radius:12px;padding:12px;text-align:center;cursor:pointer;transition:all 0.2s" onclick="navigator.clipboard.writeText('${path}');this.querySelector('.gallery-copied').style.opacity=1;setTimeout(()=>this.querySelector('.gallery-copied').style.opacity=0,1000)" onmouseover="this.style.borderColor='#58a6ff';this.style.transform='translateY(-2px)'" onmouseout="this.style.borderColor='#30363d';this.style.transform=''">
<img src="${path}" style="width:80px;height:80px;border-radius:14px;border:2px solid #30363d;object-fit:cover;margin-bottom:8px">
<div style="font-weight:600;font-size:13px;color:#f0f6fc">${a.name}</div>
${a.role ? `<div style="font-size:11px;color:#58a6ff;margin-top:2px">${a.role}</div>` : ''}
<div style="font-size:10px;color:#484f58;margin-top:4px;font-family:monospace">${a.file}</div>
<div class="gallery-copied" style="font-size:10px;color:#3fb950;margin-top:4px;opacity:0;transition:opacity 0.3s">Kopioitu!</div>
</div>`;
}).join('');
})();
// Ladataan agentit kun builder-tabi avataan
builderLoad();
// GUIDE.md:n lataus ja renderöinti
(async function loadGuide() {
const container = document.getElementById('guide-content');
if (!container) return;
try {
const res = await fetch('/GUIDE.md');
if (!res.ok) { container.innerHTML = '<p style="color:#f85149">Oppaan lataus epäonnistui.</p>'; return; }
const md = await res.text();
container.innerHTML = renderMarkdown(md);
// Syntaksikorostus koodiblokeille
container.querySelectorAll('pre code').forEach(block => {
if (typeof hljs !== 'undefined') hljs.highlightElement(block);
});
// Mermaid-kaaviot
if (typeof mermaid !== 'undefined') {
mermaid.initialize({ startOnLoad: false, theme: 'dark', themeVariables: { primaryColor: '#58a6ff', primaryTextColor: '#c9d1d9', lineColor: '#30363d', background: '#0d1117' } });
container.querySelectorAll('.mermaid-container').forEach(async el => {
try {
const { svg } = await mermaid.render('m-' + el.id, el.textContent.trim());
el.innerHTML = svg;
} catch(e) { /* fallback: jätetään teksti näkyviin */ }
});
}
} catch(e) {
container.innerHTML = '<p style="color:#f85149">Virhe: ' + e.message + '</p>';
}
})();
function renderMarkdown(md) {
const lines = md.split('\n');
let html = '';
let inCode = false;
let codeLang = '';
let codeBuffer = '';
let inTable = false;
let tableRows = [];
function flushTable() {
if (!inTable) return;
inTable = false;
if (tableRows.length < 2) return;
const headerCells = tableRows[0].split('|').filter(c => c.trim());
const bodyRows = tableRows.slice(2); // Skip header + separator
html += '<div style="overflow-x:auto;margin:16px 0"><table style="width:100%;border-collapse:collapse;font-size:14px">';
html += '<thead><tr>' + headerCells.map(c => `<th style="text-align:left;padding:8px 12px;border-bottom:2px solid #30363d;color:#58a6ff;font-weight:600">${inlineFormat(c.trim())}</th>`).join('') + '</tr></thead>';
html += '<tbody>';
for (const row of bodyRows) {
const cells = row.split('|').filter(c => c.trim());
if (cells.length === 0) continue;
html += '<tr>' + cells.map(c => `<td style="padding:6px 12px;border-bottom:1px solid #21262d">${inlineFormat(c.trim())}</td>`).join('') + '</tr>';
}
html += '</tbody></table></div>';
tableRows = [];
}
function inlineFormat(text) {
return text
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;border:1px solid #30363d;margin:12px 0;display:block">')
.replace(/`([^`]+)`/g, '<code style="background:#161b22;padding:2px 6px;border-radius:3px;font-size:13px;color:#e6edf3">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong style="color:#e6edf3">$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
}
for (const line of lines) {
// Koodiblokit + Mermaid-kaaviot
if (line.startsWith('```')) {
if (inCode) {
if (codeLang === 'mermaid') {
const mermaidId = 'mermaid-' + Math.random().toString(36).slice(2, 8);
html += `<div class="mermaid-container" id="${mermaidId}" style="margin:16px 0;text-align:center">${codeBuffer.replace(/</g,'&lt;')}</div>`;
} else {
html += `<pre style="background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:14px;margin:12px 0;overflow-x:auto"><code class="language-${codeLang}">${codeBuffer.replace(/</g,'&lt;')}</code></pre>`;
}
inCode = false;
codeBuffer = '';
} else {
flushTable();
inCode = true;
codeLang = line.slice(3).trim() || 'plaintext';
}
continue;
}
if (inCode) { codeBuffer += (codeBuffer ? '\n' : '') + line; continue; }
// Taulukot
if (line.includes('|') && line.trim().startsWith('|')) {
if (!inTable) inTable = true;
tableRows.push(line);
continue;
} else {
flushTable();
}
// Tyhjä rivi
if (!line.trim()) { html += '<div style="height:8px"></div>'; continue; }
// Otsikot
if (line.startsWith('# ')) { html += `<h1 style="color:#e6edf3;font-size:28px;margin:32px 0 12px;border-bottom:1px solid #30363d;padding-bottom:8px">${inlineFormat(line.slice(2))}</h1>`; continue; }
if (line.startsWith('## ')) { html += `<h2 style="color:#e6edf3;font-size:22px;margin:28px 0 10px;border-bottom:1px solid #21262d;padding-bottom:6px">${inlineFormat(line.slice(3))}</h2>`; continue; }
if (line.startsWith('### ')) { html += `<h3 style="color:#e6edf3;font-size:17px;margin:20px 0 8px">${inlineFormat(line.slice(4))}</h3>`; continue; }
// Horisontaalinen viiva
if (line.match(/^-{3,}$/)) { html += '<hr style="border:none;border-top:1px solid #30363d;margin:20px 0">'; continue; }
// Lista
if (line.match(/^[\-\*] /)) {
html += `<div style="padding:2px 0 2px 20px">${inlineFormat(line.replace(/^[\-\*] /, '• '))}</div>`;
continue;
}
if (line.match(/^\d+\. /)) {
html += `<div style="padding:2px 0 2px 20px">${inlineFormat(line)}</div>`;
continue;
}
// Normaali tekstirivi
html += `<p style="margin:4px 0">${inlineFormat(line)}</p>`;
}
flushTable();
return html;
}
</script>
<!-- Prompt-editori modal -->
<div id="prompt-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:1000;backdrop-filter:blur(4px)">
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#0d1117;border:1px solid #30363d;border-radius:8px;width:700px;max-width:90vw;max-height:85vh;overflow-y:auto;padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<span id="prompt-modal-title" style="font-weight:600;font-size:15px;color:#58a6ff"></span>
<button onclick="closePromptModal()" style="background:none;border:none;color:#8b949e;font-size:20px;cursor:pointer;padding:0 4px">&times;</button>
</div>
<div id="prompt-modal-fields" style="display:flex;flex-direction:column;gap:10px"></div>
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
<button onclick="closePromptModal()" style="background:#161b22;border:1px solid #30363d;color:#8b949e;padding:6px 16px;border-radius:4px;cursor:pointer">Sulje</button>
<button onclick="rerunFromModal()" style="background:#238636;border:1px solid #2ea043;color:white;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600">▶ Aja uudelleen</button>
</div>
</div>
</div>
</body>
</html>