- Hub: uusi POST /api/v1/model endpoint, broadcastaa change_model - Native node: kuuntelee change_model, kutsuu Ollaman pull + vaihtaa mallin - Frontend: kpn load näyttää 5 mallia, numero vaihtaa Ollaman mallin - Selain-WASM pysyy 0.5B:nä (kpn load 1) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3853 lines
205 KiB
HTML
3853 lines
205 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fi">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Kipinä Agentic Playground</title>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
|
||
<script 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>
|
||
<span style="color:#79c0ff">"prompt"</span>: <span style="color:#a5d6ff">"Write a bubble sort"</span>,<br>
|
||
<span style="color:#79c0ff">"system"</span>: <span style="color:#a5d6ff">"You are a Python expert. Write only code."</span>,<br>
|
||
<span style="color:#79c0ff">"max_tokens"</span>: <span style="color:#79c0ff">128</span>,<br>
|
||
<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">◯</span>
|
||
<span>WebAssembly-ytimen lataus</span>
|
||
</div>
|
||
<div class="code-step" id="step-tokenizer">
|
||
<span class="step-icon">◯</span>
|
||
<span>Tokenizer (7 MB)</span>
|
||
</div>
|
||
<div class="code-step" id="step-model">
|
||
<span class="step-icon">◯</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">◯</span>
|
||
<span>Mallin rakentaminen muistiin</span>
|
||
</div>
|
||
<div class="code-step" id="step-ready">
|
||
<span class="step-icon">◯</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 "kirjoita hello world"" 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
// 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();
|
||
});
|
||
|
||
// Kytkemme sivuston UI-puolen (JS) omaan passiiviseen WebSocket-kuuntelijaan.
|
||
const uiSocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`);
|
||
window._uiSocket = uiSocket;
|
||
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';
|
||
}
|
||
};
|
||
// 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">[>]</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, ' ');
|
||
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 <malli> "<prompti>" — aja tehtävä verkossa', '#a5d6ff');
|
||
termLog(' kpn pipeline "<tehtävä>" — nopea: manageri → koodari → testaaja', '#a5d6ff');
|
||
termLog(' kpn project "<kuvaus>" — 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', type: 'selain + Ollama' },
|
||
{ id: '2', name: 'qwen2.5-coder:1.5b', size: '~1 GB', type: 'Ollama GPU' },
|
||
{ id: '3', name: 'qwen2.5-coder:7b', size: '~4.7 GB', type: 'Ollama GPU', default: true },
|
||
{ id: '4', name: 'qwen2.5-coder:14b', size: '~9 GB', type: 'Ollama GPU' },
|
||
{ id: '5', name: 'qwen2.5-coder:32b', size: '~20 GB', type: 'Ollama GPU' },
|
||
];
|
||
if (!arg) {
|
||
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 <numero>', '#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 "<tehtävä>"', '#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 "<projektin kuvaus>"', '#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 <agentti/malli> "<prompti>"', '#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());
|
||
|
||
uiSocket.onmessage = (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">◠</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) {}
|
||
};
|
||
|
||
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(' · ');
|
||
|
||
// 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,'<')}</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,'<')}</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>
|