Frontend lähettää agentin asetukset (system_prompt, temperature, top_k, max_tokens, repeat_penalty, stop) API:lle. Hub välittää ne solmulle. Native-node ja Wasm-coder käyttävät välitettyjä arvoja hardkoodattujen sijaan.
1239 lines
65 KiB
Plaintext
1239 lines
65 KiB
Plaintext
---
|
|
import "../styles/global.css";
|
|
import StatusBar from "../components/StatusBar.astro";
|
|
import Terminal from "../components/Terminal.astro";
|
|
import Editor from "../components/Editor.astro";
|
|
import Guide from "../components/Guide.astro";
|
|
import AgentBar from "../components/AgentBar.astro";
|
|
import Settings from "../components/Settings.astro";
|
|
---
|
|
<!DOCTYPE html>
|
|
<html lang="fi">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Kipinä Agentic Playground</title>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
|
<script type="module">
|
|
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
|
|
window.mermaid = mermaid;
|
|
</script>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css">
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
|
|
<div>
|
|
<h1 style="margin-bottom:0"><span style="color:#ff6b00">Kipinä</span> Agentic Playground</h1>
|
|
<p style="color:#8b949e;margin:0">AI-ohjelmistokehitystiimi · <span id="hub-version">-</span></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Välilehdet -->
|
|
<div class="tabs">
|
|
<div class="tab active" onclick="switchTab('agents')">Agentit</div>
|
|
<div class="tab" onclick="switchTab('editor')">Editor</div>
|
|
<div class="tab" onclick="switchTab('guide')">Opas</div>
|
|
<div class="tab" onclick="switchTab('settings')">Asetukset</div>
|
|
</div>
|
|
|
|
<!-- Agents-paneeli -->
|
|
<div id="panel-agents" class="panel active">
|
|
<AgentBar />
|
|
<StatusBar />
|
|
<Terminal />
|
|
</div>
|
|
|
|
<Editor />
|
|
<Guide />
|
|
<Settings />
|
|
</div>
|
|
|
|
<script is:inline>
|
|
// === Helpers ===
|
|
window.showJoinDialog = function() {
|
|
const d = document.getElementById('join-dialog');
|
|
d.style.display = d.style.display === 'none' ? 'block' : 'none';
|
|
};
|
|
|
|
function esc(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
function highlightCode(code) {
|
|
if (typeof hljs !== 'undefined') {
|
|
try { return hljs.highlightAuto(code).value; } catch(e) {}
|
|
}
|
|
return esc(code);
|
|
}
|
|
|
|
// === Globaalit tilat ===
|
|
const defaultAgents = {
|
|
manager: { name: 'Manageri', avatar: '/avatars/karhunpentu.webp', model: 'qwen-coder', order: 0,
|
|
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
|
|
prompt: `You are a senior project manager and software architect. Your job is to plan the file structure of a software project.
|
|
|
|
RULES:
|
|
- List each source file on its own line in format: filename.py: one-line description
|
|
- List dependency files FIRST (models.py, schemas.py before main.py)
|
|
- Use pyproject.toml for Python dependencies (never requirements.txt)
|
|
- Maximum 4-5 files per project
|
|
- Only filenames, no directory paths
|
|
- Common patterns: models.py → schemas.py → main.py → pyproject.toml
|
|
|
|
EXAMPLE OUTPUT:
|
|
models.py: SQLAlchemy database models and engine setup
|
|
schemas.py: Pydantic request/response schemas
|
|
main.py: FastAPI application with CRUD endpoints
|
|
pyproject.toml: project dependencies` },
|
|
coder: { name: 'Koodari', avatar: '/avatars/kipina_notext.webp', model: 'qwen-coder', order: 1,
|
|
temperature: 0.7, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
|
|
prompt: `You are an expert Python developer. Write complete, production-ready code.
|
|
|
|
CRITICAL RULES:
|
|
1. Include ALL imports at the top of every file
|
|
2. Import from other project files: from models import User, SessionLocal
|
|
3. Pydantic schemas use different names than SQLAlchemy models: UserCreate, UserResponse (not User)
|
|
4. SQLAlchemy engine: create_engine(url, connect_args={"check_same_thread": False})
|
|
5. SessionLocal: sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
6. FastAPI dependencies: def get_db(): db = SessionLocal(); try: yield db; finally: db.close()
|
|
7. Pydantic v2: use model_dump() not dict(), class Config: from_attributes = True
|
|
8. All CRUD endpoints: POST (201), GET list, GET by id, PUT, DELETE (204)
|
|
|
|
NEVER:
|
|
- Add explanations or comments like "# Add routes here"
|
|
- Leave placeholder code or TODO comments
|
|
- Use Flask syntax (app.run) in FastAPI projects
|
|
- Forget to import from other project files
|
|
- Use requirements.txt or Poetry — always use pyproject.toml with [project] format (PEP 621)
|
|
- Use pip install — use uv (e.g. uv run uvicorn main:app --reload)` },
|
|
data: { name: 'Data', avatar: '/avatars/pesukarhu_notext.webp', model: 'qwen-coder', order: 2,
|
|
temperature: 0.5, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
|
|
prompt: `You are a database architect specializing in SQLAlchemy and relational databases.
|
|
|
|
YOUR RESPONSIBILITIES:
|
|
1. Design normalized database schemas with proper column types and constraints
|
|
2. Define SQLAlchemy models with __tablename__, primary keys, indexes, and relationships
|
|
3. Set up engine, SessionLocal, and Base in the same file (models.py or database.py)
|
|
4. Use String(length) not bare String for SQLite compatibility
|
|
5. Add nullable=False for required fields, unique=True where appropriate
|
|
6. Use Column(Integer, primary_key=True, index=True) for IDs
|
|
|
|
ALWAYS INCLUDE:
|
|
- from sqlalchemy import create_engine, Column, Integer, String
|
|
- from sqlalchemy.ext.declarative import declarative_base
|
|
- from sqlalchemy.orm import sessionmaker
|
|
- DATABASE_URL, engine, SessionLocal, Base` },
|
|
qa: { name: 'QA', avatar: '/avatars/susi_notext.webp', model: 'qwen-coder', order: 3,
|
|
temperature: 0.4, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
|
|
prompt: `You are a QA engineer writing automated tests.
|
|
|
|
WRITE TESTS USING:
|
|
- pytest as the test framework
|
|
- FastAPI TestClient for API endpoint testing
|
|
- SQLAlchemy in-memory SQLite for test database isolation
|
|
|
|
TEST STRUCTURE:
|
|
1. test_create: POST valid data → 201, verify response matches input
|
|
2. test_list: GET collection → 200, verify array response
|
|
3. test_get_by_id: GET with valid/invalid ID → 200/404
|
|
4. test_update: PUT with valid data → 200, verify changes persisted
|
|
5. test_delete: DELETE → 204, verify GET returns 404 after
|
|
|
|
ALWAYS: from fastapi.testclient import TestClient` },
|
|
tester: { name: 'DevOps', avatar: '/avatars/laiskiainen_notext.webp', model: 'qwen-coder', order: 4,
|
|
temperature: 0.3, topK: 40, repeatPenalty: 1.1, maxTokens: 512,
|
|
prompt: `You are a strict code reviewer and static analysis expert. Analyze the code line by line.
|
|
|
|
STATIC ANALYSIS CHECKLIST:
|
|
1. IMPORTS: Every "from X import Y" must match an actual export in file X
|
|
2. NAMES: Pydantic schemas (UserCreate) must not shadow SQLAlchemy models (User)
|
|
3. TYPES: All function parameters have type hints, return types specified
|
|
4. ERRORS: Every db query that can return None has a 404 check
|
|
5. RESOURCES: Database session uses yield+finally pattern (no leaks)
|
|
6. SECURITY: No raw SQL, no hardcoded secrets, inputs validated via Pydantic
|
|
7. ENDPOINTS: All CRUD operations exist (POST/GET/GET-by-id/PUT/DELETE)
|
|
8. MODELS: Pydantic Config has from_attributes=True, uses model_dump() not dict()
|
|
9. COMPLETENESS: No placeholder comments, no "TODO", no "pass" in handlers
|
|
|
|
RESPOND:
|
|
- If all checks pass: "LGTM"
|
|
- If issues found: list each as "ISSUE: filename.py: description"
|
|
- Be specific and actionable, not vague` },
|
|
observer: { name: 'Tarkkailija', avatar: '/avatars/aikuinen_susi.webp', model: 'qwen-coder', order: 5,
|
|
temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 512,
|
|
prompt: `You are an independent technical observer and risk analyst.
|
|
|
|
EVALUATE THE PROJECT FOR:
|
|
1. ARCHITECTURE: Is the file structure logical? Are responsibilities separated?
|
|
2. SECURITY: SQL injection risks? Input validation? Authentication?
|
|
3. RELIABILITY: Error handling? Database connection management? Edge cases?
|
|
4. MAINTAINABILITY: Consistent naming? Clear code structure? Would a new developer understand this?
|
|
|
|
OUTPUT FORMAT:
|
|
- RISK: [critical/high/medium/low] Description
|
|
- List max 3-5 most important findings
|
|
- End with overall assessment: "SHIP IT" or "NEEDS WORK: reason"` },
|
|
};
|
|
// Versio: kasvata kun oletuspromptit muuttuvat
|
|
const AGENTS_VERSION = 2;
|
|
let agents;
|
|
const savedVersion = parseInt(localStorage.getItem('kpn-agents-version') || '0');
|
|
if (savedVersion < AGENTS_VERSION && localStorage.getItem('kpn-agents')) {
|
|
// Uudet oletukset saatavilla — kysytään käyttäjältä
|
|
if (confirm('Agenttien oletuspromptit on päivitetty. Haluatko ottaa uudet käyttöön?\n\n(OK = päivitä oletuksiin, Peruuta = säilytä omat muokkauksesi)')) {
|
|
agents = JSON.parse(JSON.stringify(defaultAgents));
|
|
localStorage.setItem('kpn-agents', JSON.stringify(agents));
|
|
} else {
|
|
agents = JSON.parse(localStorage.getItem('kpn-agents'));
|
|
}
|
|
localStorage.setItem('kpn-agents-version', String(AGENTS_VERSION));
|
|
} else {
|
|
agents = JSON.parse(localStorage.getItem('kpn-agents') || 'null') || JSON.parse(JSON.stringify(defaultAgents));
|
|
localStorage.setItem('kpn-agents-version', String(AGENTS_VERSION));
|
|
}
|
|
function saveAgents() { localStorage.setItem('kpn-agents', JSON.stringify(agents)); }
|
|
function getAgentModel(name) { const a = agents[name]; return a ? a.model : name; }
|
|
|
|
// LLM-asetukset (localStorage-persistenssi)
|
|
const defaultSettings = {
|
|
systemPrompt: "You are a coding assistant. Respond with ONLY code. No explanations, no markdown fences, no 'Please note' text. Only working code with proper imports.",
|
|
temperature: 0.7,
|
|
topK: 40,
|
|
repeatPenalty: 1.15,
|
|
maxTokens: 1024,
|
|
stopSequences: "\\n###\\n\\nExplanation\\nNote:\\nPlease note\\nThis is a basic\\n```\\n\\n\\n// Example\\n# Example",
|
|
model: "qwen2.5-coder:3b",
|
|
};
|
|
let settings = JSON.parse(localStorage.getItem('kpn-settings') || 'null') || { ...defaultSettings };
|
|
function saveSettings() { localStorage.setItem('kpn-settings', JSON.stringify(settings)); }
|
|
|
|
// === Tab switching ===
|
|
window.switchTab = function(tab) {
|
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.getElementById('panel-' + tab)?.classList.add('active');
|
|
document.querySelector(`.tab[onclick*="${tab}"]`)?.classList.add('active');
|
|
window.location.hash = tab;
|
|
if (tab === 'editor') initMonaco();
|
|
};
|
|
const initHash = window.location.hash.replace('#','');
|
|
if (['editor','guide'].includes(initHash)) switchTab(initHash);
|
|
|
|
// === Agent selection & config ===
|
|
let selectedAgent = null;
|
|
|
|
// Highlightaa aktiivinen agentti pipeline-aikana
|
|
function highlightAgent(agentKey) {
|
|
document.querySelectorAll('.agent-avatar').forEach(el => el.classList.remove('active'));
|
|
if (agentKey) document.querySelector(`.agent-avatar[data-agent="${agentKey}"]`)?.classList.add('active');
|
|
}
|
|
|
|
function renderAgentBar() {
|
|
const bar = document.getElementById('agent-bar');
|
|
const sorted = Object.entries(agents).sort((a,b) => (a[1].order||0) - (b[1].order||0));
|
|
bar.innerHTML = sorted.map(([key, a]) =>
|
|
`<div class="agent-avatar${selectedAgent===key?' active':''}" data-agent="${key}" onclick="selectAgent('${key}')" draggable="true" ondragstart="dragAgent(event,'${key}')" ondragover="event.preventDefault()" ondrop="dropAgent(event,'${key}')" title="${esc(a.name)}">` +
|
|
`<img src="${a.avatar}" alt="${esc(a.name)}">` +
|
|
`<div class="avatar-name">${esc(a.name)}</div></div>`
|
|
).join('');
|
|
}
|
|
renderAgentBar();
|
|
|
|
window.selectAgent = function(key) {
|
|
selectedAgent = (selectedAgent === key) ? null : key; // Toggle
|
|
renderAgentBar();
|
|
const config = document.getElementById('agent-config');
|
|
if (!selectedAgent) { config.style.display = 'none'; return; }
|
|
|
|
// Tarkkailija-klikkaus: avaa raportti modalina jos se on generoitu
|
|
if (key === 'observer' && window._lastReport) {
|
|
config.style.display = 'none';
|
|
showReportModal(window._lastReport);
|
|
selectedAgent = null;
|
|
renderAgentBar();
|
|
return;
|
|
}
|
|
|
|
const a = agents[key];
|
|
config.style.display = 'block';
|
|
document.getElementById('config-avatar').src = a.avatar;
|
|
document.getElementById('config-name').value = a.name;
|
|
document.getElementById('config-role').textContent = key;
|
|
document.getElementById('config-model').value = a.model;
|
|
const promptEl = document.getElementById('config-prompt');
|
|
promptEl.value = a.prompt || '';
|
|
// Auto-resize: textarea kasvaa sisällön mukaan
|
|
promptEl.style.height = 'auto';
|
|
promptEl.style.height = promptEl.scrollHeight + 'px';
|
|
|
|
// Sampling-parametrit
|
|
const tempEl = document.getElementById('config-temperature');
|
|
const tempValEl = document.getElementById('config-temp-val');
|
|
const maxtokEl = document.getElementById('config-maxtokens');
|
|
const maxtokValEl = document.getElementById('config-maxtok-val');
|
|
const topkEl = document.getElementById('config-topk');
|
|
const topkValEl = document.getElementById('config-topk-val');
|
|
const repEl = document.getElementById('config-repeat');
|
|
const repValEl = document.getElementById('config-rep-val');
|
|
|
|
tempEl.value = a.temperature ?? 0.7; tempValEl.textContent = tempEl.value;
|
|
maxtokEl.value = a.maxTokens ?? 1024; maxtokValEl.textContent = maxtokEl.value;
|
|
topkEl.value = a.topK ?? 40; topkValEl.textContent = topkEl.value;
|
|
repEl.value = a.repeatPenalty ?? 1.15; repValEl.textContent = repEl.value;
|
|
|
|
// Pipeline-järjestys
|
|
const pipeline = document.getElementById('config-pipeline');
|
|
const sorted = Object.entries(agents).sort((a,b) => (a[1].order||0) - (b[1].order||0));
|
|
pipeline.innerHTML = sorted.map(([k,ag]) =>
|
|
`<span style="padding:3px 8px;border-radius:4px;font-size:11px;border:1px solid ${k===key?'var(--accent)':'var(--border)'};color:${k===key?'var(--accent)':'#8b949e'};cursor:grab" draggable="true" ondragstart="dragAgent(event,'${k}')" ondragover="event.preventDefault()" ondrop="dropAgent(event,'${k}')">${ag.name}</span>`
|
|
).join('');
|
|
|
|
// Muutosten tallennus
|
|
document.getElementById('config-name').oninput = () => { agents[key].name = document.getElementById('config-name').value; saveAgents(); renderAgentBar(); };
|
|
document.getElementById('config-model').onchange = () => { agents[key].model = document.getElementById('config-model').value; saveAgents(); };
|
|
promptEl.oninput = () => { agents[key].prompt = promptEl.value; saveAgents(); promptEl.style.height = 'auto'; promptEl.style.height = promptEl.scrollHeight + 'px'; };
|
|
tempEl.oninput = () => { agents[key].temperature = +tempEl.value; tempValEl.textContent = tempEl.value; saveAgents(); };
|
|
maxtokEl.oninput = () => { agents[key].maxTokens = +maxtokEl.value; maxtokValEl.textContent = maxtokEl.value; saveAgents(); };
|
|
topkEl.oninput = () => { agents[key].topK = +topkEl.value; topkValEl.textContent = topkEl.value; saveAgents(); };
|
|
repEl.oninput = () => { agents[key].repeatPenalty = +repEl.value; repValEl.textContent = repEl.value; saveAgents(); };
|
|
};
|
|
|
|
window.closeAgentConfig = function() { selectedAgent = null; document.getElementById('agent-config').style.display = 'none'; renderAgentBar(); };
|
|
|
|
// Drag & drop järjestys
|
|
let dragKey = null;
|
|
window.dragAgent = function(e, key) { dragKey = key; e.dataTransfer.effectAllowed = 'move'; };
|
|
window.dropAgent = function(e, targetKey) {
|
|
e.preventDefault();
|
|
if (!dragKey || dragKey === targetKey) return;
|
|
const srcOrder = agents[dragKey].order;
|
|
const tgtOrder = agents[targetKey].order;
|
|
agents[dragKey].order = tgtOrder;
|
|
agents[targetKey].order = srcOrder;
|
|
saveAgents(); renderAgentBar();
|
|
if (selectedAgent) selectAgent(selectedAgent); // Päivitä pipeline-näkymä
|
|
};
|
|
|
|
// Uuden agentin luonti
|
|
window.addCustomAgent = function() {
|
|
const id = 'custom_' + Date.now();
|
|
const avatars = ['/avatars/bear.webp','/avatars/beaver.webp','/avatars/gecko.webp','/avatars/lion.webp','/avatars/penguin.webp','/avatars/spider.webp','/avatars/walrus.webp','/avatars/serpent.webp'];
|
|
agents[id] = {
|
|
name: 'Uusi agentti',
|
|
avatar: avatars[Math.floor(Math.random() * avatars.length)],
|
|
model: 'qwen-coder',
|
|
prompt: '',
|
|
order: Object.keys(agents).length,
|
|
};
|
|
saveAgents(); renderAgentBar(); selectAgent(id);
|
|
};
|
|
|
|
// Agentin poisto
|
|
window.deleteAgent = function() {
|
|
if (!selectedAgent || !agents[selectedAgent]) return;
|
|
if (!confirm(`Poistetaanko ${agents[selectedAgent].name}?`)) return;
|
|
delete agents[selectedAgent];
|
|
saveAgents(); selectedAgent = null;
|
|
document.getElementById('agent-config').style.display = 'none';
|
|
renderAgentBar();
|
|
};
|
|
|
|
// === WebSocket ===
|
|
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
|
|
const uiSocket = new WebSocket(wsUrl);
|
|
window._uiSocket = uiSocket;
|
|
|
|
uiSocket.onopen = async () => {
|
|
document.getElementById('hub-dot').style.background = '#3fb950';
|
|
document.getElementById('hub-label').textContent = 'Yhdistetty';
|
|
document.getElementById('hub-label').style.color = '#3fb950';
|
|
// Rekisteröidy viewerina
|
|
uiSocket.send(JSON.stringify({
|
|
type: 'auth', status: 'viewer', node_type: 'browser',
|
|
platform: navigator.platform || '', cpu_cores: navigator.hardwareConcurrency || 0,
|
|
device_memory_gb: navigator.deviceMemory || 0, allocated_gb: 0, selected_task: 'viewer',
|
|
}));
|
|
// Tarkistetaan onko natiivisolmu jo hubissa
|
|
try {
|
|
const res = await fetch('/api/v1/chat/completions', {
|
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify({ model: 'qwen-coder', prompt: 'ping', task_id: 'status-check' }),
|
|
signal: AbortSignal.timeout(3000),
|
|
});
|
|
if (res.status !== 503) {
|
|
// Solmu löytyi (natiivi tai Wasm)
|
|
document.getElementById('compute-dot').style.background = '#3fb950';
|
|
document.getElementById('compute-label').textContent = 'Valmis (natiivi)';
|
|
document.getElementById('compute-label').style.color = '#3fb950';
|
|
document.getElementById('compute-btn').textContent = '✓ Valmis';
|
|
document.getElementById('compute-btn').className = 'btn btn-green';
|
|
llmReady = true;
|
|
}
|
|
} catch(e) {
|
|
// Timeout = solmu on olemassa mutta laskee — se on ok
|
|
document.getElementById('compute-dot').style.background = '#3fb950';
|
|
document.getElementById('compute-label').textContent = 'Valmis (natiivi)';
|
|
document.getElementById('compute-label').style.color = '#3fb950';
|
|
document.getElementById('compute-btn').textContent = '✓ Valmis';
|
|
document.getElementById('compute-btn').className = 'btn btn-green';
|
|
llmReady = true;
|
|
}
|
|
};
|
|
uiSocket.onclose = () => {
|
|
document.getElementById('hub-dot').style.background = '#f85149';
|
|
document.getElementById('hub-label').textContent = 'Yhteys katkennut';
|
|
document.getElementById('hub-label').style.color = '#f85149';
|
|
};
|
|
|
|
// === Terminal ===
|
|
const termPanel = document.getElementById('terminal');
|
|
const termInput = document.getElementById('term-input');
|
|
const termHistory = [];
|
|
let termHistIdx = -1;
|
|
|
|
function termLog(html, color) {
|
|
const div = document.createElement('div');
|
|
div.className = 'terminal-line';
|
|
if (color) div.style.color = color;
|
|
div.innerHTML = html;
|
|
termPanel.appendChild(div);
|
|
while (termPanel.children.length > 100 && !termPanel.firstChild.querySelector('.stream-content')) termPanel.removeChild(termPanel.firstChild);
|
|
termPanel.scrollTop = termPanel.scrollHeight;
|
|
}
|
|
|
|
// === Wasm inference (main thread) ===
|
|
let wasmReady = false;
|
|
let wasmNodeStarted = false;
|
|
let llmReady = false;
|
|
|
|
async function ensureWasm() {
|
|
if (wasmReady) return;
|
|
const { default: init, start_agent_node, set_gpu_load } = await import('/pkg/node.js');
|
|
window._wasmExports = { init, start_agent_node, set_gpu_load };
|
|
termLog(' Ladataan WebAssembly...', '#d29922');
|
|
await init();
|
|
wasmReady = true;
|
|
termLog(' <span style="color:#3fb950">✓</span> WebAssembly valmis');
|
|
}
|
|
|
|
async function ensureNode() {
|
|
if (wasmNodeStarted) return;
|
|
await ensureWasm();
|
|
const { start_agent_node } = window._wasmExports;
|
|
const deviceInfo = JSON.stringify({
|
|
allocated_gb: 4,
|
|
cpu_cores: navigator.hardwareConcurrency || 0,
|
|
device_memory_gb: navigator.deviceMemory || 0,
|
|
platform: navigator.platform || '',
|
|
gpu: null,
|
|
selected_task: 'qwen-coder-05b'
|
|
});
|
|
termLog(' Yhdistetään laskentasolmuna...', '#d29922');
|
|
await start_agent_node(wsUrl, false, deviceInfo, 4);
|
|
wasmNodeStarted = true;
|
|
|
|
// Odotetaan WS-yhteyden avautumista (kuunnellaan console.log)
|
|
await new Promise(resolve => {
|
|
const origLog = console.log;
|
|
const check = (...args) => {
|
|
const msg = args.join(' ');
|
|
if (msg.includes('Yhteys Hubiin avattu')) {
|
|
console.log = origLog;
|
|
resolve();
|
|
}
|
|
};
|
|
console.log = function(...args) { origLog.apply(console, args); check(...args); };
|
|
// Timeout 15s
|
|
setTimeout(() => { console.log = origLog; resolve(); }, 15000);
|
|
});
|
|
|
|
document.getElementById('compute-dot').style.background = '#d29922';
|
|
document.getElementById('compute-label').textContent = 'Yhdistetty';
|
|
document.getElementById('compute-label').style.color = '#d29922';
|
|
termLog(' <span style="color:#3fb950">✓</span> Laskentasolmu yhdistetty hubiin');
|
|
}
|
|
|
|
// Kuunnellaan console.log mallin latauksen etenemiselle
|
|
const _origLog = console.log;
|
|
console.log = function(...args) {
|
|
_origLog.apply(console, args);
|
|
const msg = args.join(' ');
|
|
if (msg.includes('[Coder]') && msg.includes('Malli ladattu')) {
|
|
llmReady = true;
|
|
document.getElementById('compute-dot').style.background = '#3fb950';
|
|
document.getElementById('compute-label').textContent = 'Qwen2.5-Coder:0.5B';
|
|
document.getElementById('compute-label').style.color = '#3fb950';
|
|
const btn = document.getElementById('compute-btn');
|
|
if (btn) { btn.textContent = '✓ Valmis'; btn.className = 'btn btn-green'; }
|
|
localStorage.setItem('kpn-coder-loaded', 'true');
|
|
}
|
|
};
|
|
|
|
// Compute-nappi
|
|
document.getElementById('compute-btn')?.addEventListener('click', () => {
|
|
const btn = document.getElementById('compute-btn');
|
|
if (btn.textContent.includes('Valmis')) return;
|
|
btn.textContent = 'Ladataan...';
|
|
btn.className = 'btn btn-muted';
|
|
ensureNode();
|
|
});
|
|
|
|
// Wasm-autostart vain jos natiivisolmua ei löydy (tarkistetaan onopen:ssa)
|
|
|
|
// === kpnRun: lähettää promptin mallille ===
|
|
const activeStreams = {};
|
|
|
|
async function kpnRun(model, prompt, silent, agentOpts) {
|
|
const taskId = crypto.randomUUID();
|
|
const statusDiv = document.createElement('div');
|
|
statusDiv.className = 'terminal-line';
|
|
statusDiv.id = 'status-' + taskId;
|
|
statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:var(--accent)">${model}</span> käsittelee...</span>`;
|
|
termPanel.appendChild(statusDiv);
|
|
termPanel.scrollTop = termPanel.scrollHeight;
|
|
|
|
try {
|
|
// Ei odotetaan Wasmia — lähetetään suoraan hubille.
|
|
// Jos hub löytää natiivisolmun, vastaus tulee nopeasti.
|
|
// Jos 503, käynnistetään Wasm-fallback.
|
|
|
|
if (!silent) {
|
|
const streamDiv = document.createElement('div');
|
|
streamDiv.className = 'terminal-line';
|
|
streamDiv.innerHTML = ' <span class="stream-content"></span><span style="color:#8b949e;animation:blink 1s infinite">▌</span>';
|
|
termPanel.appendChild(streamDiv);
|
|
termPanel.scrollTop = termPanel.scrollHeight;
|
|
activeStreams[taskId] = streamDiv;
|
|
}
|
|
|
|
statusDiv.innerHTML = ` <span style="color:#8b949e">→ <span style="color:var(--accent)">${model}</span> käsittelee...</span>`;
|
|
|
|
// Rakennetaan pyyntö: agentin asetukset tai globaalit oletukset
|
|
const opts = agentOpts || {};
|
|
const payload = {
|
|
model,
|
|
prompt,
|
|
task_id: taskId,
|
|
system_prompt: opts.systemPrompt || settings.systemPrompt || undefined,
|
|
temperature: opts.temperature ?? settings.temperature ?? undefined,
|
|
top_k: opts.topK ?? settings.topK ?? undefined,
|
|
max_tokens: opts.maxTokens ?? settings.maxTokens ?? undefined,
|
|
repeat_penalty: opts.repeatPenalty ?? settings.repeatPenalty ?? undefined,
|
|
stop: settings.stopSequences ? settings.stopSequences.split('\\n').filter(Boolean) : undefined,
|
|
};
|
|
|
|
const res = await fetch('/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (res.status === 503 && !wasmNodeStarted) {
|
|
// Ei natiivisolmua — käynnistetään Wasm-fallback
|
|
statusDiv.innerHTML = ' <span style="color:#d29922">→ Ei natiivisolmua — käynnistetään selainlaskenta...</span>';
|
|
await ensureNode();
|
|
for (let i = 0; i < 120 && !llmReady; i++) await new Promise(r => setTimeout(r, 500));
|
|
if (!llmReady) { statusDiv.innerHTML = ' <span style="color:#f85149">✗ Mallin lataus aikakatkaistiin</span>'; return null; }
|
|
// Yritetään uudelleen
|
|
return kpnRun(model, prompt, silent);
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.text().catch(() => res.statusText);
|
|
statusDiv.innerHTML = ` <span style="color:#f85149">✗ ${esc(err)}</span>`;
|
|
return null;
|
|
}
|
|
const data = await res.json();
|
|
const response = (data.response || '').trim();
|
|
const tokGen = data.tokens_generated || 0;
|
|
const durS = data.duration_ms ? (data.duration_ms / 1000).toFixed(1) + 's' : '';
|
|
const tokS = data.tokens_per_sec ? data.tokens_per_sec.toFixed(1) + ' tok/s' : '';
|
|
statusDiv.innerHTML = ` <span style="color:#3fb950">✓</span> <span style="color:var(--accent)">${esc(data.model || model)}</span> <span style="color:#8b949e">${tokGen} tok · ${durS} · ${tokS}</span>`;
|
|
|
|
if (!silent && response) {
|
|
const firstLine = response.split('\n').find(l => l.trim()) || response;
|
|
const lineCount = response.split('\n').filter(l => l.trim()).length;
|
|
const uid = 'code-' + Date.now();
|
|
termLog(
|
|
` <span style="color:#3fb950;cursor:pointer" onclick="document.getElementById('${uid}').style.display=document.getElementById('${uid}').style.display==='none'?'block':'none'">`
|
|
+ `<span style="color:#8b949e">▶</span> ${esc(firstLine.trim())} <span style="color:#8b949e">${lineCount > 1 ? '(+' + (lineCount-1) + ' riviä)' : ''}</span></span>`
|
|
+ `<pre id="${uid}" style="display:none;margin:4px 0 0 16px;font:inherit;white-space:pre-wrap;border-left:2px solid var(--border);padding-left:10px">${highlightCode(response)}</pre>`
|
|
);
|
|
}
|
|
return response;
|
|
} catch(e) {
|
|
statusDiv.innerHTML = ` <span style="color:#f85149">✗ ${esc(e.message)}</span>`;
|
|
return null;
|
|
} finally {
|
|
if (activeStreams[taskId]) { activeStreams[taskId].remove(); delete activeStreams[taskId]; }
|
|
}
|
|
}
|
|
|
|
// === WebSocket message handler ===
|
|
uiSocket.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'stats') {
|
|
document.getElementById('hub-version').textContent = 'v' + (data.version || '?');
|
|
} else if (data.type === 'task_routed') {
|
|
const statusDiv = document.getElementById('status-' + data.task_id);
|
|
if (statusDiv) {
|
|
const color = data.status === 'queued' ? '#d29922' : '#8b949e';
|
|
statusDiv.innerHTML = ` <span style="color:${color}">${data.status === 'queued' ? '⏳' : '→'} ${esc(data.message)}</span>`;
|
|
}
|
|
} else if (data.type === 'llm_chunk' && data.task_id && activeStreams[data.task_id]) {
|
|
const el = activeStreams[data.task_id].querySelector('.stream-content');
|
|
if (el) { el.textContent += data.token || ''; termPanel.scrollTop = termPanel.scrollHeight; }
|
|
}
|
|
} catch(e) {}
|
|
};
|
|
|
|
// === Terminal commands ===
|
|
const kpnCommands = {
|
|
'kpn': ['help','run','project','pipeline','load','status','models','clear'],
|
|
'kpn run': ['coder','coder-3b','manager','tester','qa','qwen-coder','smollm-135m'],
|
|
'kpn load': ['1','2'],
|
|
'kpn project': ['"'],
|
|
'kpn pipeline': ['"'],
|
|
};
|
|
const kpnExamples = {
|
|
'kpn run coder': ['"hello world in python"','"fibonacci in rust"','"quicksort in javascript"'],
|
|
'kpn run coder-3b': ['"REST API with Flask"','"binary search tree"'],
|
|
'kpn project': ['"FastAPI + SQLite REST API"','"CLI tool for CSV processing"'],
|
|
'kpn pipeline': ['"todo-sovellus"','"laskin pythonilla"'],
|
|
};
|
|
|
|
// Tab completion
|
|
function getCompletions(val) {
|
|
const words = val.trimEnd().split(/\s+/);
|
|
for (let d = words.length; d >= 1; d--) {
|
|
const prefix = words.slice(0,d).join(' ');
|
|
const partial = words[d] || '';
|
|
if (kpnExamples[prefix] && !partial) return { items: kpnExamples[prefix], prefix: prefix + ' ' };
|
|
const cands = kpnCommands[prefix];
|
|
if (cands) {
|
|
const m = partial ? cands.filter(c => c.startsWith(partial)) : cands;
|
|
if (m.length > 0) return { items: m, prefix: prefix + ' ' };
|
|
}
|
|
}
|
|
if (!val.trim()) return { items: kpnCommands['kpn'] || [], prefix: 'kpn ' };
|
|
return { items: [], prefix: val };
|
|
}
|
|
|
|
// Dropdown
|
|
const dropdown = document.getElementById('term-dropdown');
|
|
let ddItems = [], ddIdx = -1, ddPrefix = '';
|
|
|
|
function showDD(items, prefix) {
|
|
if (!items.length) { hideDD(); return; }
|
|
ddItems = items; ddPrefix = prefix; ddIdx = -1;
|
|
dropdown.innerHTML = items.map((item,i) =>
|
|
`<div class="dd-item" data-i="${i}" onclick="selectDD()">${esc(item)}</div>`
|
|
).join('');
|
|
dropdown.style.display = 'block';
|
|
dropdown.querySelectorAll('.dd-item').forEach(el => {
|
|
el.addEventListener('mouseenter', () => highlightDD(+el.dataset.i));
|
|
});
|
|
}
|
|
function hideDD() { dropdown.style.display = 'none'; ddItems = []; ddIdx = -1; }
|
|
function highlightDD(i) {
|
|
ddIdx = i;
|
|
dropdown.querySelectorAll('.dd-item').forEach((el,j) => el.classList.toggle('active', j===i));
|
|
dropdown.children[i]?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
window.selectDD = function() {
|
|
if (ddIdx >= 0 && ddIdx < ddItems.length)
|
|
termInput.value = ddPrefix + ddItems[ddIdx] + (ddItems[ddIdx].startsWith('"') ? '' : ' ');
|
|
hideDD(); termInput.focus();
|
|
};
|
|
|
|
// Agenttiprompti-mapping
|
|
const agentModels = new Proxy({}, { get: (_, key) => getAgentModel(key) });
|
|
|
|
function termExec(cmd) {
|
|
termLog(`<span class="terminal-prompt">$</span> ${esc(cmd)}`);
|
|
termHistory.unshift(cmd); termHistIdx = -1;
|
|
const parts = cmd.trim().split(/\s+/);
|
|
if (parts[0] !== 'kpn') { termLog(' Tuntematon komento. Kokeile: kpn help', '#f85149'); return; }
|
|
const sub = parts[1];
|
|
|
|
if (sub === 'help' || !sub) {
|
|
termLog(' kpn run <malli> "prompti" — aja tehtävä', '#a5d6ff');
|
|
termLog(' kpn project "kuvaus" — monivaiheinen projekti', '#a5d6ff');
|
|
termLog(' kpn pipeline "tehtävä" — nopea: manageri→koodari→testaaja', '#a5d6ff');
|
|
termLog(' kpn load — lataa kielimalli', '#a5d6ff');
|
|
termLog(' kpn models — mallit', '#a5d6ff');
|
|
termLog(' kpn status — verkon tila', '#a5d6ff');
|
|
termLog(' kpn clear — tyhjennä', '#a5d6ff');
|
|
} else if (sub === 'clear') { termPanel.innerHTML = '';
|
|
} else if (sub === 'load') {
|
|
const btn = document.getElementById('compute-btn');
|
|
if (btn && btn.textContent.includes('Valmis')) { termLog(' ✓ Malli jo ladattu', '#3fb950'); }
|
|
else { btn?.click(); }
|
|
} else if (sub === 'models') {
|
|
termLog(' <span style="color:var(--accent)">1</span> qwen-coder Qwen2.5-Coder:0.5B <span style="color:#8b949e">~990 MB</span>');
|
|
termLog(' <span style="color:var(--accent)">2</span> qwen-coder-3b Qwen2.5-Coder:3B <span style="color:#8b949e">~6.2 GB</span>');
|
|
termLog(' <span style="color:var(--accent)">3</span> smollm-135m SmolLM 135M <span style="color:#8b949e">~270 MB</span>');
|
|
} else if (sub === 'status') {
|
|
termLog(` Hub: ${document.getElementById('hub-label').textContent} | Laskenta: ${document.getElementById('compute-label').textContent}`, '#a5d6ff');
|
|
} else if (sub === 'run') {
|
|
let model = parts[2];
|
|
const after = cmd.replace(/^kpn\s+run\s+\S+\s*/, '');
|
|
const m = after.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
|
|
const prompt = (m && (m[1]||m[2]||m[3]||'')).trim();
|
|
if (!model || !prompt) { termLog(' Käyttö: kpn run <malli> "prompti"', '#f85149'); return; }
|
|
if (agentModels[model]) model = agentModels[model];
|
|
kpnRun(model, prompt);
|
|
} else if (sub === 'project') {
|
|
const after = cmd.replace(/^kpn\s+project\s*/, '');
|
|
const m = after.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
|
|
const task = (m && (m[1]||m[2]||m[3]||'')).trim();
|
|
if (!task) { termLog(' Käyttö: kpn project "kuvaus"', '#f85149'); return; }
|
|
kpnProject(task);
|
|
} else if (sub === 'pipeline') {
|
|
const after = cmd.replace(/^kpn\s+pipeline\s*/, '');
|
|
const m = after.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
|
|
const task = (m && (m[1]||m[2]||m[3]||'')).trim();
|
|
if (!task) { termLog(' Käyttö: kpn pipeline "tehtävä"', '#f85149'); return; }
|
|
kpnPipelineSimple(task);
|
|
} else { termLog(` Tuntematon: ${sub}. Kokeile: kpn help`, '#f85149'); }
|
|
}
|
|
|
|
// Input handler
|
|
termInput?.addEventListener('keydown', (e) => {
|
|
if (dropdown.style.display === 'block') {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); highlightDD(Math.min(ddIdx+1, ddItems.length-1)); return; }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); highlightDD(Math.max(ddIdx-1, 0)); return; }
|
|
if ((e.key === 'Enter' || e.key === 'Tab') && ddIdx >= 0) { e.preventDefault(); selectDD(); return; }
|
|
if (e.key === 'Escape') { e.preventDefault(); hideDD(); return; }
|
|
}
|
|
if (e.key === 'Tab' && e.shiftKey) {
|
|
e.preventDefault(); hideDD();
|
|
const val = termInput.value.trimEnd();
|
|
if (!val) return;
|
|
const qm = val.match(/^(.+\s)".*"?$|^(.+\s)'.*'?$/);
|
|
if (qm) termInput.value = (qm[1]||qm[2]).trimEnd() + ' ';
|
|
else { const ls = val.lastIndexOf(' '); termInput.value = ls > 0 ? val.substring(0, ls+1) : ''; }
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const { items, prefix } = getCompletions(termInput.value);
|
|
if (items.length === 1) { termInput.value = prefix + items[0] + (items[0].startsWith('"') ? '' : ' '); hideDD(); }
|
|
else if (items.length > 1) showDD(items, prefix);
|
|
} else if (e.key === 'Enter') {
|
|
hideDD();
|
|
const cmd = termInput.value.trim();
|
|
if (cmd) termExec(cmd);
|
|
termInput.value = '';
|
|
} else if (e.key === 'ArrowUp') { e.preventDefault(); if (termHistIdx < termHistory.length-1) { termHistIdx++; termInput.value = termHistory[termHistIdx]; }
|
|
} else if (e.key === 'ArrowDown') { e.preventDefault(); if (termHistIdx > 0) { termHistIdx--; termInput.value = termHistory[termHistIdx]; } else { termHistIdx=-1; termInput.value=''; }
|
|
}
|
|
});
|
|
termPanel?.addEventListener('click', () => termInput?.focus());
|
|
document.addEventListener('click', (e) => { if (!termInput?.contains(e.target) && !dropdown?.contains(e.target)) hideDD(); });
|
|
|
|
// === Template-pohjainen projektipipeline ===
|
|
let templates = {};
|
|
|
|
// Ladataan mallipohjat
|
|
(async () => {
|
|
try {
|
|
const res = await fetch('/templates/fastapi-crud.json');
|
|
if (res.ok) { const t = await res.json(); templates[t.name] = t; }
|
|
} catch(e) {}
|
|
})();
|
|
|
|
function explainStep(title, explanation) {
|
|
termLog(`\n <span style="color:#a371f7;font-size:12px">💡 ${esc(title)}</span>`);
|
|
termLog(` <span style="color:#8b949e;font-size:12px">${esc(explanation)}</span>`);
|
|
}
|
|
|
|
async function kpnProject(task) {
|
|
const cdr = agents.coder || Object.values(agents)[1];
|
|
|
|
// Etsitään sopivin mallipohja
|
|
const template = Object.values(templates)[0]; // Toistaiseksi vain FastAPI CRUD
|
|
if (!template) {
|
|
termLog(' ✗ Mallipohjia ei ladattu', '#f85149');
|
|
return;
|
|
}
|
|
|
|
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ ${esc(template.name)} — ${esc(task)} ━━━</span>`);
|
|
explainStep('Mallipohja', `Käytetään "${template.name}" -mallipohjaa jossa ${template.order.length} tiedostoa: ${template.order.join(', ')}. Jokainen tiedosto generoidaan järjestyksessä, ja aiemmat tiedostot annetaan kontekstina seuraavalle.`);
|
|
|
|
const files = {};
|
|
|
|
for (let i = 0; i < template.order.length; i++) {
|
|
const fileName = template.order[i];
|
|
const fileDef = template.files[fileName];
|
|
if (!fileDef) continue;
|
|
|
|
const step = i + 1;
|
|
// Valitaan oikea agentti tiedostotyypin mukaan
|
|
const isDbFile = fileName === 'models.py' || fileName === 'database.py';
|
|
const dataAgent = agents.data || Object.values(agents)[2];
|
|
const fileAgent = isDbFile && dataAgent ? dataAgent : cdr;
|
|
const fileAgentKey = isDbFile && dataAgent ? 'data' : 'coder';
|
|
|
|
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step}/${template.order.length}] ${esc(fileAgent.name)}</span> — ${esc(fileName)}`);
|
|
highlightAgent(fileAgentKey);
|
|
|
|
// Opettava selitys: miksi tämä tiedosto, mitä se sisältää
|
|
explainStep(fileName, fileDef.instructions);
|
|
|
|
// Rakennetaan prompti: esimerkki + konteksti + ohje
|
|
let prompt = '';
|
|
|
|
// Agentin system prompt (data-agentti models.py:lle, koodari muille)
|
|
if (fileAgent.prompt) prompt += fileAgent.prompt + '\n\n';
|
|
|
|
// Esimerkki (few-shot)
|
|
prompt += `EXAMPLE of ${fileName} (for a different project, adapt to this one):\n`;
|
|
prompt += '```\n' + fileDef.example + '\n```\n\n';
|
|
|
|
// Aiemmin generoidut tiedostot (konteksti)
|
|
const prevFiles = Object.entries(files);
|
|
if (prevFiles.length > 0) {
|
|
prompt += 'Already written files in THIS project:\n';
|
|
for (const [name, code] of prevFiles) {
|
|
prompt += `--- ${name} ---\n${code}\n\n`;
|
|
}
|
|
}
|
|
|
|
// Tehtävä
|
|
prompt += `NOW write "${fileName}" for THIS project: ${task}\n`;
|
|
prompt += fileDef.instructions + '\n';
|
|
prompt += 'Adapt the example to match the project description. Import from already written files. Write ONLY the code, no explanations.';
|
|
|
|
const code = await kpnRun(fileAgent.model, prompt, false, fileAgent);
|
|
if (!code) {
|
|
termLog(` ✗ Keskeytyi (${fileName})`, '#f85149');
|
|
return;
|
|
}
|
|
files[fileName] = code;
|
|
}
|
|
|
|
const allCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
|
|
let stepN = template.order.length + 1;
|
|
|
|
// Review-korjausluuppi: max 2 kierrosta
|
|
const tst = agents.tester || Object.values(agents)[4];
|
|
const MAX_REVIEW_ROUNDS = 3;
|
|
|
|
for (let round = 0; round < MAX_REVIEW_ROUNDS; round++) {
|
|
const currentCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
|
|
|
|
// DevOps review
|
|
termLog(`\n<span style="color:var(--accent);font-weight:bold">[${stepN}] ${esc(tst.name)}</span> — koodikatselmointi${round > 0 ? ' (kierros '+(round+1)+')' : ''}`);
|
|
highlightAgent('tester');
|
|
if (round === 0) explainStep('Koodikatselmointi', `${tst.name} analysoi koodin rivi riviltä: importit, nimeämiset, virheenkäsittely, tietoturva.`);
|
|
else explainStep('Uudelleentarkistus', `${tst.name} tarkistaa korjaukset.`);
|
|
|
|
const reviewPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') + `Review this project:\n\n${currentCode}`;
|
|
const review = await kpnRun(tst.model, reviewPrompt, false, tst);
|
|
stepN++;
|
|
|
|
// LGTM → ei korjauksia tarvita
|
|
if (!review || review.toLowerCase().includes('lgtm')) {
|
|
termLog(` <span style="color:#3fb950">✓ ${esc(tst.name)}: LGTM</span>`);
|
|
break;
|
|
}
|
|
|
|
// Korjaukset
|
|
termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepN}] ${esc(cdr.name)}</span> — korjaukset${round > 0 ? ' (kierros '+(round+1)+')' : ''}`);
|
|
highlightAgent('coder');
|
|
explainStep('Korjaus', `${tst.name} löysi ongelmia. ${cdr.name} saa palautteen ja korjaa.`);
|
|
|
|
const fixPrompt = `${cdr.prompt ? cdr.prompt+'\n\n' : ''}Fix these issues:\n${review}\n\nCurrent code:\n${currentCode}\n\nWrite ALL corrected files. Start each file with: --- filename.py ---`;
|
|
const fixedCode = await kpnRun(cdr.model, fixPrompt, false, cdr);
|
|
|
|
// Parsitaan korjatut tiedostot takaisin files-objektiin
|
|
if (fixedCode) {
|
|
const fixedParts = fixedCode.split(/^---\s*(\S+)\s*---$/m);
|
|
for (let j = 1; j < fixedParts.length; j += 2) {
|
|
const fname = fixedParts[j].trim();
|
|
const fcode = (fixedParts[j+1] || '').trim();
|
|
if (fname && fcode && files[fname] !== undefined) {
|
|
files[fname] = fcode;
|
|
}
|
|
}
|
|
}
|
|
stepN++;
|
|
} // for review round
|
|
|
|
// Päivitetään allCode korjausten jälkeen
|
|
const updatedCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
|
|
|
|
// QA: testit (saa korjatut tiedostot)
|
|
const qaAgent = agents.qa || Object.values(agents)[3];
|
|
if (qaAgent) {
|
|
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — testit`);
|
|
highlightAgent('qa');
|
|
explainStep('Testit', `${qaAgent.name} kirjoittaa pytest-testit korjatulle koodille.`);
|
|
const qaPrompt = (qaAgent.prompt ? qaAgent.prompt+'\n\n' : '') + `Write pytest tests for this project:\n\n${updatedCode}\n\nWrite a complete test_main.py file with TestClient.`;
|
|
const tests = await kpnRun(qaAgent.model, qaPrompt, false, qaAgent);
|
|
if (tests) files['test_main.py'] = tests;
|
|
stepN++;
|
|
}
|
|
|
|
// DevOps: Dockerfile (saa kaikki tiedostot mukaan lukien testit)
|
|
const allFilesNow = Object.keys(files).join(', ');
|
|
termLog(`\n<span style="color:var(--accent);font-weight:bold">[${stepN}] ${esc(tst.name)}</span> — Dockerfile`);
|
|
highlightAgent('tester');
|
|
explainStep('Dockerfile', `${tst.name} generoi Docker-kontin kaikista ${Object.keys(files).length} tiedostosta: ${allFilesNow}`);
|
|
const dockerPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') +
|
|
`Write a Dockerfile for this Python FastAPI project.\n\n` +
|
|
`Project files: ${allFilesNow}\n\n` +
|
|
`Requirements:\n` +
|
|
`- Use python:3.12-slim as base\n` +
|
|
`- Install uv: COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv\n` +
|
|
`- Copy pyproject.toml first, then uv sync, then copy source\n` +
|
|
`- Expose port 8000\n` +
|
|
`- CMD: uv run uvicorn main:app --host 0.0.0.0 --port 8000\n` +
|
|
`\nWrite ONLY the Dockerfile, no explanations.`;
|
|
const dockerfile = await kpnRun(tst.model, dockerPrompt, false, tst);
|
|
if (dockerfile) files['Dockerfile'] = dockerfile;
|
|
stepN++;
|
|
|
|
// Tarkkailija: yhteenveto + raportti + arvosana
|
|
const obs = agents.observer || Object.values(agents)[5];
|
|
if (obs) {
|
|
termLog(`\n<span style="color:#8b949e;font-weight:bold">[${stepN}] ${esc(obs.name)}</span> — projektin yhteenveto`);
|
|
highlightAgent('observer');
|
|
explainStep('Raportti', `${obs.name} kokoaa yhteenvedon ja antaa arvosanan.`);
|
|
const finalCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
|
|
const fileList = Object.keys(files).join(', ');
|
|
const obsPrompt = (obs.prompt ? obs.prompt+'\n\n' : '') +
|
|
`Write a project report in clean markdown for: ${task}\n\n` +
|
|
`FIRST LINE must be exactly one of:\n` +
|
|
`VERDICT: GREEN\nVERDICT: ORANGE\nVERDICT: RED\n\n` +
|
|
`Then write this report:\n\n` +
|
|
`# ${task}\n\n` +
|
|
`## Overview\nOne paragraph describing what this project does.\n\n` +
|
|
`## Files\n| File | Purpose |\n|------|---------|` +
|
|
Object.entries(files).map(([n]) => `\n| ${n} | ... |`).join('') + `\n\n` +
|
|
`## Quick Start\n` +
|
|
'```bash\n' +
|
|
`git clone <repo>\ncd project\nuv sync\nuv run uvicorn main:app --reload\n` +
|
|
'```\n\n' +
|
|
`## Docker\n` +
|
|
'```bash\n' +
|
|
`docker build -t ${task.toLowerCase().replace(/[^a-z0-9]/g, '-')} .\ndocker run -p 8000:8000 ${task.toLowerCase().replace(/[^a-z0-9]/g, '-')}\n` +
|
|
'```\n\n' +
|
|
`## API Endpoints\n| Method | Path | Description |\n|--------|------|-------------|` +
|
|
`\n| POST | /items/ | Create |\n| GET | /items/ | List all |\n| GET | /items/{id} | Get by ID |\n| PUT | /items/{id} | Update |\n| DELETE | /items/{id} | Delete |\n` +
|
|
`(Adapt paths and descriptions to match the actual code)\n\n` +
|
|
`## Architecture\nDescribe the project structure and design decisions.\n\n` +
|
|
`## Risk Assessment\n| Severity | Issue |\n|----------|-------|\n| ... | ... |\n\n` +
|
|
`Project code:\n${finalCode}`;
|
|
const readme = await kpnRun(obs.model, obsPrompt, false, obs);
|
|
if (readme) {
|
|
files['README.md'] = readme;
|
|
// Tallennetaan raportti globaalisti jotta tarkkailija-klikkaus avaa sen
|
|
window._lastReport = readme;
|
|
// Parsitaan arvosana → tarkkailijan kehäväri
|
|
const firstLine = readme.split('\n')[0].toUpperCase();
|
|
let verdictColor = '#3fb950'; // oletus: vihreä
|
|
let verdictText = 'OK';
|
|
if (firstLine.includes('RED')) { verdictColor = '#f85149'; verdictText = 'KRIITTISTÄ'; }
|
|
else if (firstLine.includes('ORANGE')) { verdictColor = '#d29922'; verdictText = 'HUOMIOITA'; }
|
|
// Asetetaan tarkkailijan kehäväri
|
|
const obsAvatar = document.querySelector('.agent-avatar[data-agent="observer"] img');
|
|
if (obsAvatar) {
|
|
obsAvatar.style.borderColor = verdictColor;
|
|
obsAvatar.style.boxShadow = `0 0 12px ${verdictColor}`;
|
|
}
|
|
termLog(` <span style="color:${verdictColor};font-weight:bold">● ${verdictText}</span> — klikkaa Tarkkailijaa nähdäksesi raportin`);
|
|
}
|
|
stepN++;
|
|
}
|
|
|
|
// Pipeline valmis
|
|
highlightAgent(null);
|
|
|
|
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis (${Object.keys(files).length} tiedostoa) ━━━</span>`);
|
|
explainStep('Tulos', `Projekti "${task}" generoitu ${Object.keys(files).length} tiedostoon. Klikkaa "Avaa editorissa" tutkiaksesi koodia ja README:tä.`);
|
|
renderProjectCard(files, task);
|
|
}
|
|
|
|
async function kpnPipelineSimple(task) {
|
|
termLog(`<span style="color:var(--purple);font-weight:bold">━━━ Pipeline ━━━</span>`);
|
|
termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span>`);
|
|
const plan = await kpnRun('qwen-coder', `Analyse briefly, write a spec:\n${task}`);
|
|
if (!plan) return;
|
|
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span>`);
|
|
const code = await kpnRun('qwen-coder', `${plan}\n\nWrite the code.`);
|
|
if (!code) return;
|
|
termLog(`\n<span style="color:var(--accent);font-weight:bold">[3/3] Testaaja</span>`);
|
|
await kpnRun('qwen-coder', `Review briefly:\n${code}`);
|
|
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Valmis ━━━</span>`);
|
|
}
|
|
|
|
// === Project card ===
|
|
window._projectFiles = {}; // id → files
|
|
|
|
function renderProjectCard(files, name) {
|
|
const entries = Object.entries(files);
|
|
if (!entries.length) return;
|
|
const id = 'proj-' + Date.now();
|
|
window._projectFiles[id] = files;
|
|
|
|
const tabs = entries.map(([n],i) =>
|
|
`<div class="project-tab${i===0?' active':''}" data-card="${id}" data-i="${i}" onclick="switchProjTab('${id}',${i})">${esc(n)}</div>`
|
|
).join('');
|
|
|
|
const panels = entries.map(([n,c],i) =>
|
|
`<div class="proj-panel" data-card="${id}" data-i="${i}" style="${i>0?'display:none':''}">` +
|
|
`<div style="text-align:right;padding:4px 8px;background:var(--bg);border-bottom:1px solid #21262d">` +
|
|
`<button class="btn btn-muted" onclick="copyProjectFile('${id}','${esc(n)}')">Kopioi</button></div>` +
|
|
`<pre class="code-block">${highlightCode(c)}</pre></div>`
|
|
).join('');
|
|
|
|
const html = `<div id="${id}" class="project-card">` +
|
|
`<div class="project-header">` +
|
|
`<span style="color:var(--purple);font-weight:600">${esc(name||'Projekti')} <span style="color:#8b949e;font-weight:normal">(${entries.length})</span></span>` +
|
|
`<span style="display:flex;gap:6px">` +
|
|
`<button class="btn btn-muted" onclick="copyAllProjectFiles('${id}')">Kopioi kaikki</button>` +
|
|
`<button class="btn btn-green" onclick="openInEditor(window._projectFiles['${id}'])">Avaa editorissa</button>` +
|
|
`</span></div>` +
|
|
`<div class="project-tabs">${tabs}</div>${panels}</div>`;
|
|
|
|
const div = document.createElement('div');
|
|
div.innerHTML = html;
|
|
termPanel.appendChild(div.firstElementChild);
|
|
termPanel.scrollTop = termPanel.scrollHeight;
|
|
}
|
|
window.copyProjectFile = function(id, name) {
|
|
const files = window._projectFiles[id];
|
|
if (files && files[name]) navigator.clipboard.writeText(files[name]);
|
|
};
|
|
window.copyAllProjectFiles = function(id) {
|
|
const files = window._projectFiles[id];
|
|
if (!files) return;
|
|
const text = Object.entries(files).map(([n,c]) => '# --- ' + n + ' ---\n' + c).join('\n\n');
|
|
navigator.clipboard.writeText(text);
|
|
};
|
|
window.switchProjTab = function(id,i) {
|
|
document.querySelectorAll(`.project-tab[data-card="${id}"]`).forEach((t,j) => t.classList.toggle('active', j===i));
|
|
document.querySelectorAll(`.proj-panel[data-card="${id}"]`).forEach((p,j) => p.style.display = j===i ? '' : 'none');
|
|
};
|
|
|
|
// === Monaco Editor (lazy load) ===
|
|
window.MonacoEnvironment = { getWorkerUrl: () => `data:text/javascript,self.MonacoEnvironment={baseUrl:'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/'};importScripts('https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js')` };
|
|
|
|
let _monacoInitPromise = null;
|
|
function initMonaco() {
|
|
if (_monacoInitPromise) return _monacoInitPromise;
|
|
_monacoInitPromise = new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
|
|
s.onerror = () => reject(new Error('Monaco loader lataus epäonnistui'));
|
|
s.onload = () => {
|
|
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }});
|
|
require(['vs/editor/editor.main'], () => {
|
|
window.monaco = monaco;
|
|
window._monaco = monaco.editor.create(document.getElementById('monaco-container'), {
|
|
value: '// Valitse tiedosto tai generoi projekti\n',
|
|
language: 'plaintext', theme: 'vs-dark', fontSize: 14,
|
|
minimap: { enabled: false }, automaticLayout: true, padding: { top: 10 }
|
|
});
|
|
console.log('[Monaco] Valmis');
|
|
resolve();
|
|
}, (err) => reject(new Error('Monaco AMD load: ' + err)));
|
|
};
|
|
document.head.appendChild(s);
|
|
});
|
|
return _monacoInitPromise;
|
|
}
|
|
|
|
window._editorModels = {};
|
|
const langMap = {py:'python',rs:'rust',js:'javascript',ts:'typescript',toml:'toml',json:'json',html:'html',css:'css',md:'markdown',txt:'plaintext'};
|
|
|
|
window.openInEditor = async function(files) {
|
|
switchTab('editor');
|
|
try { await initMonaco(); } catch(e) { console.error('Monaco-virhe:', e); return; }
|
|
const m = window.monaco;
|
|
if (!m) { console.error('Monaco ei latautunut'); return; }
|
|
for (const [name,code] of Object.entries(files)) {
|
|
const ext = name.split('.').pop().toLowerCase();
|
|
if (window._editorModels[name]) window._editorModels[name].setValue(code);
|
|
else window._editorModels[name] = m.editor.createModel(code, langMap[ext]||'plaintext');
|
|
}
|
|
document.getElementById('editor-file-list').innerHTML = Object.keys(files).map(n => `<div class="dd-item" onclick="openFile('${n}')">${n}</div>`).join('');
|
|
document.getElementById('editor-tabs').innerHTML = Object.keys(files).map(n => `<div class="project-tab" onclick="openFile('${n}')">${n}</div>`).join('');
|
|
openFile(Object.keys(files)[0]);
|
|
};
|
|
window.openFile = function(name) {
|
|
if (!window._editorModels[name] || !window._monaco) return;
|
|
window._monaco.setModel(window._editorModels[name]);
|
|
document.querySelectorAll('#editor-file-list .dd-item').forEach(el => el.style.background = el.textContent===name ? 'var(--border)' : '');
|
|
document.querySelectorAll('#editor-tabs .project-tab').forEach(el => el.classList.toggle('active', el.textContent===name));
|
|
};
|
|
|
|
// === Guide loader ===
|
|
(async () => {
|
|
const el = document.getElementById('guide-content');
|
|
try {
|
|
const r = await fetch('/GUIDE.md');
|
|
if (r.ok) el.innerHTML = renderMd(await r.text());
|
|
el.querySelectorAll('pre code').forEach(b => { if (typeof hljs !== 'undefined') hljs.highlightElement(b); });
|
|
// Mermaid-kaaviot
|
|
if (window.mermaid) {
|
|
el.querySelectorAll('.mermaid-block').forEach(async block => {
|
|
try {
|
|
const { svg } = await window.mermaid.render('r-' + block.id, block.textContent.trim());
|
|
block.innerHTML = svg;
|
|
} catch(e) { /* fallback: näytetään koodi */ }
|
|
});
|
|
}
|
|
} catch(e) { el.textContent = 'Virhe: ' + e.message; }
|
|
})();
|
|
|
|
function renderMd(md) {
|
|
const lines = md.split('\n');
|
|
let html = '', inCode = false, lang = '', buf = '';
|
|
let inTable = false, tableRows = [];
|
|
|
|
function inline(text) {
|
|
return text
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:#e6edf3">$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/`([^`]+)`/g, '<code style="background:var(--panel);padding:1px 5px;border-radius:3px;font-size:13px;color:#e6edf3">$1</code>');
|
|
}
|
|
|
|
function flushTable() {
|
|
if (!inTable || tableRows.length < 2) { inTable = false; tableRows = []; return; }
|
|
const hdr = tableRows[0].split('|').filter(c => c.trim());
|
|
const body = tableRows.slice(2);
|
|
html += '<div style="overflow-x:auto;margin:12px 0"><table style="width:100%;border-collapse:collapse;font-size:14px">';
|
|
html += '<thead><tr>' + hdr.map(c => `<th style="text-align:left;padding:8px 12px;border-bottom:2px solid var(--border);color:var(--accent);font-weight:600">${inline(c.trim())}</th>`).join('') + '</tr></thead><tbody>';
|
|
for (const row of body) {
|
|
const cells = row.split('|').filter(c => c.trim());
|
|
if (cells.length) html += '<tr>' + cells.map(c => `<td style="padding:6px 12px;border-bottom:1px solid #21262d">${inline(c.trim())}</td>`).join('') + '</tr>';
|
|
}
|
|
html += '</tbody></table></div>';
|
|
inTable = false; tableRows = [];
|
|
}
|
|
|
|
for (const line of lines) {
|
|
// Koodiblokit + Mermaid
|
|
if (line.startsWith('```')) {
|
|
if (inCode) {
|
|
if (lang === 'mermaid') {
|
|
const mid = 'mmd-' + Math.random().toString(36).slice(2,8);
|
|
html += `<div class="mermaid-block" id="${mid}" style="margin:16px 0;text-align:center">${buf.replace(/</g,'<')}</div>`;
|
|
} else {
|
|
html += `<pre class="code-block"><code class="language-${lang}">${buf.replace(/</g,'<')}</code></pre>`;
|
|
}
|
|
inCode = false; buf = '';
|
|
} else {
|
|
flushTable();
|
|
inCode = true; lang = line.slice(3).trim() || 'plaintext';
|
|
}
|
|
continue;
|
|
}
|
|
if (inCode) { buf += (buf ? '\n' : '') + line; continue; }
|
|
|
|
// Taulukot
|
|
if (line.includes('|') && line.trim().startsWith('|')) {
|
|
if (!inTable) inTable = true;
|
|
tableRows.push(line);
|
|
continue;
|
|
} else { flushTable(); }
|
|
|
|
// Tyhjä rivi
|
|
if (!line.trim()) { html += '<div style="height:8px"></div>'; continue; }
|
|
|
|
// Otsikot
|
|
if (line.startsWith('# ')) { html += `<h1 style="color:#e6edf3;font-size:26px;margin:28px 0 10px;border-bottom:1px solid var(--border);padding-bottom:8px">${inline(line.slice(2))}</h1>`; continue; }
|
|
if (line.startsWith('## ')) { html += `<h2 style="color:#e6edf3;font-size:21px;margin:24px 0 8px;border-bottom:1px solid #21262d;padding-bottom:6px">${inline(line.slice(3))}</h2>`; continue; }
|
|
if (line.startsWith('### ')) { html += `<h3 style="color:#e6edf3;font-size:17px;margin:18px 0 6px">${inline(line.slice(4))}</h3>`; continue; }
|
|
|
|
// Viiva
|
|
if (line.match(/^-{3,}$/)) { html += '<hr style="border:none;border-top:1px solid var(--border);margin:20px 0">'; continue; }
|
|
|
|
// Listat
|
|
if (line.match(/^[\-\*] /)) { html += `<div style="padding:2px 0 2px 20px">${inline(line.replace(/^[\-\*] /, '• '))}</div>`; continue; }
|
|
if (line.match(/^\d+\. /)) { html += `<div style="padding:2px 0 2px 20px">${inline(line)}</div>`; continue; }
|
|
|
|
// Normaali teksti
|
|
html += `<p style="margin:4px 0">${inline(line)}</p>`;
|
|
}
|
|
flushTable();
|
|
return html;
|
|
}
|
|
// === Settings panel ===
|
|
function initSettings() {
|
|
const els = {
|
|
systemPrompt: document.getElementById('set-system-prompt'),
|
|
temperature: document.getElementById('set-temperature'),
|
|
tempVal: document.getElementById('set-temp-val'),
|
|
topK: document.getElementById('set-topk'),
|
|
topkVal: document.getElementById('set-topk-val'),
|
|
repeat: document.getElementById('set-repeat'),
|
|
repVal: document.getElementById('set-rep-val'),
|
|
maxTokens: document.getElementById('set-maxtokens'),
|
|
maxtokVal: document.getElementById('set-maxtok-val'),
|
|
stopSeq: document.getElementById('set-stop-sequences'),
|
|
model: document.getElementById('set-model'),
|
|
};
|
|
if (!els.systemPrompt) return;
|
|
|
|
// Lataa arvot
|
|
els.systemPrompt.value = settings.systemPrompt;
|
|
els.temperature.value = settings.temperature;
|
|
els.tempVal.textContent = settings.temperature;
|
|
els.topK.value = settings.topK;
|
|
els.topkVal.textContent = settings.topK;
|
|
els.repeat.value = settings.repeatPenalty;
|
|
els.repVal.textContent = settings.repeatPenalty;
|
|
els.maxTokens.value = settings.maxTokens;
|
|
els.maxtokVal.textContent = settings.maxTokens;
|
|
els.stopSeq.value = settings.stopSequences.replace(/\\n/g, '\n');
|
|
els.model.value = settings.model;
|
|
|
|
// Tallenna muutokset
|
|
els.systemPrompt.oninput = () => { settings.systemPrompt = els.systemPrompt.value; saveSettings(); };
|
|
els.temperature.oninput = () => { settings.temperature = +els.temperature.value; els.tempVal.textContent = settings.temperature; saveSettings(); };
|
|
els.topK.oninput = () => { settings.topK = +els.topK.value; els.topkVal.textContent = settings.topK; saveSettings(); };
|
|
els.repeat.oninput = () => { settings.repeatPenalty = +els.repeat.value; els.repVal.textContent = settings.repeatPenalty; saveSettings(); };
|
|
els.maxTokens.oninput = () => { settings.maxTokens = +els.maxTokens.value; els.maxtokVal.textContent = settings.maxTokens; saveSettings(); };
|
|
els.stopSeq.oninput = () => { settings.stopSequences = els.stopSeq.value.replace(/\n/g, '\\n'); saveSettings(); };
|
|
els.model.onchange = () => { settings.model = els.model.value; saveSettings(); };
|
|
}
|
|
// Alustetaan kun settings-tab avataan
|
|
const origSwitchTab = window.switchTab;
|
|
window.switchTab = function(tab) {
|
|
origSwitchTab(tab);
|
|
if (tab === 'settings') initSettings();
|
|
};
|
|
|
|
window.resetSettings = function() {
|
|
if (!confirm('Palautetaanko kaikki asetukset oletuksiin?')) return;
|
|
settings = { ...defaultSettings };
|
|
saveSettings();
|
|
initSettings();
|
|
};
|
|
// === Raportti-modal ===
|
|
window.showReportModal = function(markdown) {
|
|
let modal = document.getElementById('report-modal');
|
|
if (!modal) {
|
|
modal = document.createElement('div');
|
|
modal.id = 'report-modal';
|
|
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:1000;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)';
|
|
modal.innerHTML = `<div style="background:var(--panel);border:1px solid var(--border);border-radius:8px;max-width:800px;width:90%;max-height:85vh;overflow-y:auto;padding:24px;position:relative">
|
|
<button onclick="document.getElementById('report-modal').style.display='none'" style="position:absolute;top:12px;right:12px;background:none;border:none;color:#8b949e;font-size:20px;cursor:pointer">✕</button>
|
|
<div id="report-modal-content" style="line-height:1.7;font-size:15px"></div>
|
|
</div>`;
|
|
document.body.appendChild(modal);
|
|
}
|
|
document.getElementById('report-modal-content').innerHTML = renderMd(markdown);
|
|
document.getElementById('report-modal-content').querySelectorAll('pre code').forEach(b => { if (typeof hljs !== 'undefined') hljs.highlightElement(b); });
|
|
modal.style.display = 'flex';
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; });
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|