2761 lines
144 KiB
HTML
2761 lines
144 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>
|
||
<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;
|
||
color: var(--success-color);
|
||
white-space: pre-wrap;
|
||
overflow-x: auto;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
.code-output .keyword { color: #ff7b72; }
|
||
.code-output .string { color: #a5d6ff; }
|
||
.code-output .comment { color: #8b949e; }
|
||
|
||
.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>
|
||
|
||
<!-- 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:8px">
|
||
<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>
|
||
</div>
|
||
<div class="terminal-panel" id="agent-terminal" style="margin-top:0;border-top:none;border-radius:0">
|
||
</div>
|
||
<div style="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"
|
||
style="flex:1;background:transparent;border:none;outline:none;color:var(--success-color);font-family:inherit;font-size:inherit">
|
||
</div>
|
||
</div>
|
||
</div><!-- /panel-agents -->
|
||
|
||
</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: 'smollm-135m', default: 'Olet laadunvarmistaja (QA). Kirjoitat testejä, etsit virheitä ja varmistat, että kaikki reunatapaukset on huomioitu.' },
|
||
tester: { name: 'DevOps — System Prompt', model: 'smollm-135m', default: 'Olet DevOps-insinööri. Vastaat koodin julkaisuputkista, serveri-infrastruktuurista ja ympäristön suorituskyvystä.' },
|
||
};
|
||
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,
|
||
}));
|
||
}
|
||
};
|
||
|
||
// URL-hash navigointi
|
||
const initHash = window.location.hash.replace('#', '');
|
||
if (['codelab', 'agents'].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 = '05b'; // '05b' tai '3b'
|
||
|
||
// Mallivalinnan radio-napit
|
||
document.querySelectorAll('input[name="coder-size"]').forEach(radio => {
|
||
radio.addEventListener('change', (e) => {
|
||
coderSize = e.target.value;
|
||
// 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');
|
||
if (hubDot) hubDot.style.background = '#3fb950';
|
||
if (hubLabel) { hubLabel.textContent = 'Yhdistetty'; hubLabel.style.color = '#3fb950'; }
|
||
|
||
// 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');
|
||
if (hubDot) hubDot.style.background = '#f85149';
|
||
if (hubLabel) { hubLabel.textContent = 'Yhteys katkennut'; hubLabel.style.color = '#f85149'; }
|
||
|
||
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) {
|
||
termLog(` → <span style="color:#58a6ff">${model}</span> käsittelee...`, '#8b949e');
|
||
const taskId = crypto.randomUUID();
|
||
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 }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errText = await res.text().catch(() => res.statusText);
|
||
termLog(` ✗ ${errText}`, '#f85149');
|
||
return null;
|
||
}
|
||
const data = await res.json();
|
||
const response = (data.response || '').trim();
|
||
const tokGen = data.tokens_generated || 0;
|
||
termLog(` <span style="color:#3fb950">✓</span> <span style="color:#58a6ff">${data.model || model}</span> <span style="color:#8b949e">(${tokGen} tok)</span>`);
|
||
if (!silent) {
|
||
termLog(` ${esc(response).replace(/\n/g,'\n ')}`, '#c9d1d9');
|
||
}
|
||
return response;
|
||
} catch (e) {
|
||
termLog(` ✗ ${e.message}`, '#f85149');
|
||
return null;
|
||
} finally {
|
||
if (activeStreams[taskId]) {
|
||
activeStreams[taskId].remove();
|
||
delete activeStreams[taskId];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pipeline: manageri → koodari → testaaja
|
||
async function kpnPipeline(task) {
|
||
termLog(`<span style="color:#a371f7;font-weight:bold">━━━ Pipeline käynnistyy ━━━</span>`);
|
||
|
||
// Vaihe 1: Manageri analysoi
|
||
termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span> — tehtävän analyysi`);
|
||
const managerPrompt = `Analysoi seuraava ohjelmistokehitystehtävä ja kirjoita koodarille selkeä tekninen ohje mitä koodata. Vastaa lyhyesti.\n\nTehtävä: ${task}`;
|
||
const plan = await kpnRun(agentPrompts.manager.model, managerPrompt);
|
||
if (!plan) { termLog(' ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; }
|
||
|
||
// Vaihe 2: Koodari toteuttaa
|
||
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span> — toteutus`);
|
||
const coderPrompt = `${plan}\n\nKirjoita koodi yllä olevan ohjeen mukaisesti.`;
|
||
const code = await kpnRun(agentPrompts.coder.model, coderPrompt);
|
||
if (!code) { termLog(' ✗ Pipeline keskeytyi (koodari)', '#f85149'); return; }
|
||
|
||
// Vaihe 3: Testaaja arvioi
|
||
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[3/3] Testaaja</span> — arviointi`);
|
||
const testerPrompt = `Arvioi seuraava koodi lyhyesti. Onko siinä bugeja? Puuttuuko testejä? Anna arvosana 1-5.\n\nTehtävä: ${task}\n\nKoodi:\n${code}`;
|
||
await kpnRun(agentPrompts.tester.model, testerPrompt);
|
||
|
||
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`);
|
||
}
|
||
|
||
function termExec(cmd) {
|
||
termLog(`<span class="terminal-prompt">$</span> ${esc(cmd)}`);
|
||
termHistory.unshift(cmd);
|
||
termHistIdx = -1;
|
||
|
||
const parts = cmd.trim().split(/\s+/);
|
||
if (parts[0] !== 'kpn') {
|
||
termLog('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ä>" — manageri → koodari → testaaja', '#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 === '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(' smollm-135m — SmolLM 135M (kevyt)', '#a5d6ff');
|
||
termLog(' qwen-05b — Qwen2.5 0.5B', '#a5d6ff');
|
||
termLog(' phi3-mini — Phi-3 Mini', '#a5d6ff');
|
||
termLog(' qwen-coder — Qwen2.5-Coder 0.5B', '#a5d6ff');
|
||
termLog(' qwen-coder-3b — Qwen2.5-Coder 3B', '#a5d6ff');
|
||
return;
|
||
}
|
||
|
||
if (sub === 'pipeline') {
|
||
const afterPipeline = cmd.replace(/^kpn\s+pipeline\s*/, '');
|
||
const pMatch = afterPipeline.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
|
||
const pTask = (pMatch && (pMatch[1] || pMatch[2] || pMatch[3] || '')).trim();
|
||
if (!pTask) {
|
||
termLog(' Käyttö: kpn pipeline "<tehtävä>"', '#f85149');
|
||
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 (agentPrompts[model]) {
|
||
model = agentPrompts[model].model;
|
||
}
|
||
|
||
kpnRun(model, prompt);
|
||
return;
|
||
}
|
||
|
||
termLog(` kpn: tuntematon alikomento "${sub}". Kokeile: kpn help`, '#f85149');
|
||
}
|
||
|
||
termInput?.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
const cmd = termInput.value.trim();
|
||
if (cmd) termExec(cmd);
|
||
termInput.value = '';
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (termHistIdx < termHistory.length - 1) {
|
||
termHistIdx++;
|
||
termInput.value = termHistory[termHistIdx];
|
||
}
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (termHistIdx > 0) {
|
||
termHistIdx--;
|
||
termInput.value = termHistory[termHistIdx];
|
||
} else {
|
||
termHistIdx = -1;
|
||
termInput.value = '';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Klikkaa terminaalipaneelia → fokusoi input
|
||
termPanel?.addEventListener('click', () => termInput?.focus());
|
||
|
||
uiSocket.onmessage = (event) => {
|
||
try {
|
||
const raw = event.data;
|
||
if (raw.includes('"single_tokenize"') || raw.includes('"download_progress"')) 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';
|
||
}
|
||
} 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 ? esc(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 === "llm_prompt") {
|
||
if (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;
|
||
}
|
||
}
|
||
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')) {
|
||
// Koodari aktivoituu, jos kyse on suoraan koodarille osoitetusta mallitehtävästä (esim. network task)
|
||
document.getElementById('avatar-coder')?.classList.add('active');
|
||
} else if (model.includes('deepseek') || model.includes('r1')) {
|
||
document.getElementById('avatar-observer')?.classList.add('active');
|
||
}
|
||
// Emme enää aseta oletusagenttia, jottei tuntemattomissa verkkopyynnöissä mikään turhaan hypi silmille.
|
||
}
|
||
} 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 highlightPython(code) {
|
||
return code
|
||
// Kommentit
|
||
.replace(/(#.*)/g, '<span style="color:#8b949e">$1</span>')
|
||
// Merkkijonot (f-stringit, tavalliset)
|
||
.replace(/(f?"[^"]*"|f?'[^']*')/g, '<span style="color:#a5d6ff">$1</span>')
|
||
// Avainsanat
|
||
.replace(/\b(def|return|if|elif|else|for|while|in|not|and|or|is|import|from|class|try|except|with|as|lambda|yield|True|False|None|raise|pass|break|continue)\b/g, '<span style="color:#ff7b72">$1</span>')
|
||
// Sisäänrakennetut funktiot
|
||
.replace(/\b(print|len|range|int|str|float|list|dict|set|tuple|type|isinstance|enumerate|zip|map|filter|sorted|reversed|sum|min|max|abs|round|input|open)\b/g, '<span style="color:#d2a8ff">$1</span>')
|
||
// Numerot
|
||
.replace(/\b(\d+\.?\d*)\b/g, '<span style="color:#79c0ff">$1</span>')
|
||
// Dekoraattorit
|
||
.replace(/(@\w+)/g, '<span style="color:#d2a8ff">$1</span>');
|
||
}
|
||
|
||
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">${highlightPython(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
|
||
const origCodeLog = console.log;
|
||
const codeLogListener = (...args) => {
|
||
const msg = args.join(' ');
|
||
if (msg.includes('[Coder]') || msg.includes('Burn Wasm') || msg.includes('Kipinä Agent Node')) {
|
||
if (msg.includes('Burn Wasm')) setStep('step-wasm', 'active');
|
||
if (msg.includes('Agent Node käynnistyy')) { setStep('step-wasm', 'done'); }
|
||
if (msg.includes('[Coder]') && msg.includes('tokenizer') && msg.includes('löytyi')) { setStep('step-tokenizer', 'done'); }
|
||
if (msg.includes('[Coder]') && msg.includes('Ladataan') && msg.includes('tokenizer')) { setStep('step-tokenizer', 'active'); }
|
||
if (msg.includes('[Coder]') && 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('[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');
|
||
}
|
||
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); };
|
||
|
||
// Käynnistä Coder-node automaattisesti ensimmäisellä kerralla
|
||
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 {
|
||
if (!wasmInitialized) {
|
||
await init();
|
||
wasmInitialized = true;
|
||
}
|
||
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;
|
||
// Tunnistetaan WebGPU myös koodilaboratorion puolella
|
||
let coderHasWebGPU = false;
|
||
if (navigator.gpu) {
|
||
try {
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
if (adapter) {
|
||
try {
|
||
const testDevice = await adapter.requestDevice({ requiredLimits: { maxInterStageShaderComponents: 60 } });
|
||
coderHasWebGPU = true;
|
||
testDevice.destroy();
|
||
} catch(e) {
|
||
coderHasWebGPU = false;
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
await start_agent_node(wsUrl, coderHasWebGPU, JSON.stringify(deviceInfo), taskId);
|
||
document.getElementById('coder-status').textContent = 'Connected';
|
||
document.getElementById('coder-status').style.color = '#d29922';
|
||
coderWsReady = true;
|
||
|
||
if (pendingCodePrompt) {
|
||
setTimeout(() => {
|
||
sendCodeToHub(pendingCodePrompt);
|
||
}, 800);
|
||
pendingCodePrompt = null;
|
||
}
|
||
} catch(e) {
|
||
console.log("Coder-virhe: " + e);
|
||
document.getElementById('coder-status').textContent = 'Virhe';
|
||
document.getElementById('coder-status').style.color = '#f85149';
|
||
coderJoined = false;
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|