Files
agentic-studio/network-poc/static/index.html
jaakko 73bcd3143a WebSocket auto-reconnect: yhteys palautuu 3s kuluttua katkoksesta
connectHub() luo uuden WebSocketin ja asettaa onopen/onclose/onmessage.
onclose käynnistää 3s timerin joka kutsuu connectHub() uudelleen.
Terminaaliin tulee '↻ Yhdistetään uudelleen...' -viesti.

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

3899 lines
208 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; }
.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: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: column;
align-items: center;
margin-bottom: 40px;
perspective: 1000px;
padding: 25px 50px;
}
.org-level {
display: flex;
justify-content: center;
gap: 40px;
position: relative;
z-index: 2;
}
.org-connector {
width: 2px;
height: 40px;
background: linear-gradient(to bottom, rgba(88, 166, 255, 0.8), rgba(88, 166, 255, 0.2));
margin: 0px auto;
box-shadow: 0 0 10px rgba(88, 166, 255, 0.5);
}
.org-branch {
width: 510px;
height: 40px;
border-top: 2px solid rgba(88, 166, 255, 0.5);
border-left: 2px solid rgba(88, 166, 255, 0.5);
border-right: 2px solid rgba(88, 166, 255, 0.5);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
margin-top: 0;
margin-bottom: -2px;
box-shadow: inset 0 3px 6px -3px rgba(88, 166, 255, 0.4);
}
.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: 16px;
padding: 12px 10px;
text-align: center;
width: 130px;
opacity: 0.5;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 8px 16px rgba(0,0,0,0.3);
}
.avatar-card:hover {
opacity: 0.85;
transform: translateY(-4px) scale(1.02);
border-color: rgba(240, 246, 252, 0.3);
box-shadow: 0 12px 20px 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: 80px;
height: 80px;
border-radius: 18px;
margin-bottom: 8px;
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: 13px; color: #f0f6fc; letter-spacing: 0.5px; margin-bottom: 2px; }
.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: 60px;
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: 20px 10px; }
.org-branch { display: none; }
.org-connector { margin-bottom: 10px; height: 20px; }
.org-level { flex-wrap: wrap; justify-content: center; gap: 15px !important; }
#avatar-observer { display: block; position: relative !important; right: auto !important; top: auto !important; margin: 0 auto; margin-bottom: 15px; }
/* Avatar cards downscaling */
.avatar-card { width: 100px; padding: 8px 4px; }
.avatar-card img { width: 55px; height: 55px; margin-bottom: 4px; border-radius: 12px; }
.avatar-name { font-size: 11px; margin-bottom: 1px; }
.avatar-role { font-size: 8px; line-height: 1.1; }
/* 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; }
}
</style>
</head>
<body>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px;">
<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">Hajautettu WebGPU Laskentaverkko</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">
<div class="main-tab active" 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" onclick="switchMainTab('agents')" data-i18n="tab_agents">Kipinä Agentic Playground</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 active">
<!-- 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" 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>
</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">
<!-- Taso 1 -->
<div class="org-level">
<div class="avatar-card" id="avatar-client" data-agent="client" onclick="selectAgent('client')">
<img src="/avatars/kettu_notext.png" alt="Asiakas (Kettu)">
<div class="avatar-name">Asiakas</div>
<div class="avatar-role">Tuoteomistaja</div>
</div>
</div>
<div class="org-connector"></div>
<!-- Taso 2 -->
<div class="org-level" style="position: relative;">
<!-- Tarkkailija laitetaan erilleen kauemmas sivuun jotta se näyttää itsenäiseltä valvojalta -->
<div class="avatar-card" id="avatar-observer" data-agent="observer" onclick="selectAgent('observer')" style="position: absolute; right: calc(50% + 350px); top: 0;">
<img src="/avatars/aikuinen_susi.png" alt="Tarkkailija (Aikuinen Susi)">
<div class="avatar-name">Tarkkailija</div>
<div class="avatar-role">Laadunvalvonta</div>
</div>
<div class="avatar-card" id="avatar-kpn" data-agent="manager" onclick="selectAgent('manager')">
<img src="/avatars/karhunpentu.png" alt="Manageri (Karhunpentu)">
<div class="avatar-name">Manageri</div>
<div class="avatar-role">KPN CLI</div>
</div>
</div>
<div class="org-connector"></div>
<div class="org-branch"></div>
<!-- Taso 3 -->
<div class="org-level" style="gap: 20px;">
<div class="avatar-card" id="avatar-coder" data-agent="coder" onclick="selectAgent('coder')">
<img src="/avatars/kipina_notext.png" alt="Koodari (Salamanteri)">
<div class="avatar-name">Koodari</div>
<div class="avatar-role">SOFTAKEHITYS</div>
</div>
<div class="avatar-card" id="avatar-data" data-agent="data" onclick="selectAgent('data')">
<img src="/avatars/pesukarhu_notext.png" alt="Data-Agentti (Pesukarhu)">
<div class="avatar-name">Data</div>
<div class="avatar-role">Tietokannat</div>
</div>
<div class="avatar-card" id="avatar-qa" data-agent="qa" onclick="selectAgent('qa')">
<img src="/avatars/susi_notext.png" alt="QA (Pikkususi)">
<div class="avatar-name">QA</div>
<div class="avatar-role">Testaus</div>
</div>
<div class="avatar-card" id="avatar-tester" data-agent="tester" onclick="selectAgent('tester')">
<img src="/avatars/laiskiainen_notext.png" alt="DevOps (Laiskiainen)">
<div class="avatar-name">DevOps</div>
<div class="avatar-role">Käyttöönotto</div>
</div>
</div>
</div>
<!-- Prompt Editor -->
<div class="agent-prompt-editor" id="agent-prompt-editor" style="margin-top:20px;">
<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>
</div>
<!-- RIGHT COLUMN: Puhuvat Päät Gallery -->
<div style="flex-basis:150px; flex-shrink:0;">
<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>
<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;overflow-x:auto;white-space:nowrap"></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-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: 'qwen-coder', default: 'Olet projektipäällikkö. Jaa tehtävät osiin, priorisoi ja koordinoi tiimin työtä.' },
coder: { name: 'Koodari — System Prompt', model: 'qwen-coder', default: 'Olet kokenut ohjelmistokehittäjä. Kirjoita selkeää, testattavaa koodia ja vastaa aina koodilla.' },
data: { name: 'Data-Agentti — System Prompt', model: 'qwen-coder', default: 'Olet tietokanta-asiantuntija. Vastaat skeemojen suunnittelusta, SQL-kyselyiden optimoinnista ja datamalleista.' },
qa: { name: 'QA — System Prompt', model: 'qwen-coder', default: 'Olet laadunvarmistaja (QA). Kirjoitat testejä, etsit virheitä ja varmistat, että kaikki reunatapaukset on huomioitu.' },
tester: { name: 'DevOps — System Prompt', model: 'qwen-coder', 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.' },
};
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';
}
}
window.selectAgent = function(agent) {
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');
}
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();
};
// 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);
});
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('#', '');
if (['codelab', 'agents', 'guide'].includes(initHash)) {
switchMainTab(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';
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) {
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
termLog(' <span style="color:#d29922">↻ Yhdistetään uudelleen...</span>');
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);
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
const avatarMap = { manager: 'avatar-kpn', coder: 'avatar-coder', tester: 'avatar-tester', qa: 'avatar-qa', data: 'avatar-data' };
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}...`;
}
}
}
function renderPipelineSteps() {
const container = document.getElementById('pipeline-steps');
if (!container) return;
if (pipelineSteps.length === 0) { container.style.display = 'none'; return; }
container.style.display = 'block';
container.innerHTML = pipelineSteps.map((s, i) => {
const colors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };
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> ' : '';
// Tooltip: input/output esikatselu
const tip = esc(`${s.label}\nInput: ${(s.input || '').substring(0, 150)}\nOutput: ${(s.output || '').substring(0, 150)}`).replace(/\n/g, '&#10;');
return `<span title="${tip}" style="cursor:help"><span style="color:${iconColor}">${icon}</span> <span style="color:${color}">${esc(s.label)}</span></span>${arrow}`;
}).join('');
}
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) {
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>
</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];
// 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
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`);
pipelineStep('manager', 'Suunnittelu', 'active', task);
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 and pyproject.toml files
- No directories, no paths, just filenames
- List dependencies first, then main app
- Prefer fewer, focused files over many small ones
Project: ${task}`;
const plan = await kpnRun(agentPrompts.manager.model, managerPrompt, false, 200);
if (!plan) { termLog(' ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; }
pipelineStep('manager', 'Suunnittelu', 'done', task, 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', file.desc);
// 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"]
[project.scripts]
start = "uvicorn main:app --reload"`;
} else if (file.name === 'requirements.txt') {
extraInstructions = '\nList one dependency per line. No version pins unless necessary.';
}
const coderPrompt = `${context}Project: ${task}
Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions}
IMPORTANT: Keep the code SHORT and focused. Max ~50 lines. No comments, no docstrings, no type hints unless essential. Write minimal, working code.`;
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', file.desc, code);
}
// Vaihe 3: Testaaja arvioi koko projektin
const allCode = Object.entries(generatedFiles)
.map(([name, code]) => `--- ${name} ---\n${code}`)
.join('\n\n');
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 2}] Testaaja</span> — arviointi`);
pipelineStep('tester', 'Review', 'active', `${Object.keys(generatedFiles).length} tiedostoa`);
const reviewPrompt = `Review this project. List bugs or issues. Be brief.
If the code is correct, say "LGTM".
${allCode}`;
const review = await kpnRun(agentPrompts.tester.model, reviewPrompt, false, 200);
pipelineStep('tester', 'Review', 'done', `${Object.keys(generatedFiles).length} tiedostoa`, review);
// Vaihe 4: Korjausluuppi — jos testaaja löysi ongelmia
if (review && !review.toLowerCase().includes('lgtm') && !review.toLowerCase().includes('looks good')) {
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 a short test file (test_app.py) for this project. Use pytest. Max 3 test functions. Keep it minimal.
${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 dockerPrompt = `Write a Dockerfile for this Python project using uv package manager.
RULES:
- Base: python:3.12-slim
- Install uv: COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
- COPY pyproject.toml and then: RUN uv sync --no-dev
- COPY all .py files
- EXPOSE 8000
- CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]
- Only output Dockerfile content, no explanations
Files: ${Object.keys(generatedFiles).join(', ')}`;
const dockerfile = await kpnRun(agentPrompts.tester.model, dockerPrompt, false, 256);
if (dockerfile) generatedFiles['Dockerfile'] = dockerfile;
pipelineStep('tester', 'Dockerfile', 'done', 'Dockerfile', dockerfile);
// 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');
const composePrompt = `Write a docker-compose.yml for this project. Include:
- app service (build from Dockerfile, port mapping, restart: unless-stopped)
- db service if SQLite/PostgreSQL is used (volume for data persistence)
- Named volumes for persistent data
Only output the YAML content, nothing else.
Files: ${Object.keys(generatedFiles).join(', ')}`;
const compose = await kpnRun(agentPrompts.tester.model, composePrompt, false, 256);
if (compose) generatedFiles['docker-compose.yml'] = compose;
pipelineStep('tester', 'Compose', 'done', 'docker-compose.yml', compose);
// 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
3. Development: uv sync && uv run uvicorn main:app --reload
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);
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis (${Object.keys(generatedFiles).length} tiedostoa) ━━━</span>`);
renderProjectCard(generatedFiles, task);
}
// 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>`);
}
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');
fetch('/api/v1/model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: selected.name }),
}).then(r => r.json()).then(data => {
if (data.status === 'ok') {
termLog(` <span style="color:#3fb950">✓</span> Malli vaihdettu: ${selected.name}`, '#3fb950');
termLog(' <span style="color:#8b949e">Ollama lataa mallin ensimmäisellä pyynnöllä</span>');
// Päivitetään aktiivinen default
ollamaModels.forEach(m => m.default = false);
selected.default = true;
} else {
termLog(` ✗ Mallin vaihto epäonnistui`, '#f85149');
}
}).catch(e => 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') {
termLog(' <span style="color:#d29922">Selain (kpn load):</span>', '#c9d1d9');
termLog(' qwen-coder:0.5b <span style="color:#8b949e">~990 MB | WASM ~0.4 tok/s</span>');
termLog(' <span style="color:#3fb950">Natiivi (Ollama + GPU):</span>', '#c9d1d9');
termLog(' qwen2.5-coder:7b <span style="color:#8b949e">~4.7 GB | NVIDIA ~80 tok/s | AMD ~40 tok/s | Apple ~30 tok/s</span>');
termLog(' qwen2.5-coder:3b <span style="color:#8b949e">~1.9 GB | NVIDIA ~120 tok/s</span>');
termLog(' qwen2.5-coder:1.5b <span style="color:#8b949e">~1 GB | NVIDIA ~150 tok/s</span>');
termLog(' Vaihda malli: <span style="color:#58a6ff">OLLAMA_MODEL=qwen2.5-coder:7b</span>', '#8b949e');
termLog(' Hub reitittää automaattisesti nopeimmalle solmulle', '#8b949e');
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 REST API for users"', '"Flask todo app with database"', '"CLI tool for CSV processing in Python"'],
'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;
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
document.getElementById('avatar-kpn').classList.add('active');
}
} 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 vain omille tehtäville
document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
const model = data.model || '';
const p = data.prompt ? data.prompt.toLowerCase() : '';
if (p.includes('tiiminvetäjä') || p.includes('pilko')) {
document.getElementById('avatar-kpn')?.classList.add('active');
} else if (p.includes('arvioi seuraava koodi') || p.includes('ohjelmiston julkaisu')) {
document.getElementById('avatar-tester')?.classList.add('active');
} else if (p.includes('tervehdi')) {
document.getElementById('avatar-client')?.classList.add('active');
} else if (p.includes('test')) {
document.getElementById('avatar-qa')?.classList.add('active');
} else if (model.includes('coder') || model.includes('Coder')) {
document.getElementById('avatar-coder')?.classList.add('active');
} else if (model.includes('deepseek') || model.includes('r1')) {
document.getElementById('avatar-observer')?.classList.add('active');
}
}
}
} 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: "Hajautettu WebGPU Laskentaverkko",
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: "Decentraliserat WebGPU Beräkningsnätverk",
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: "Decentralized WebGPU Compute Network",
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);
});
// 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, '<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>
</body>
</html>