Files
didactyl/didactyl.html

1273 lines
44 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>DIDACTYL</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<style media="screen">
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 20px;
padding: 20px;
overflow-y: auto;
}
.divPanel {
border: var(--header-border);
border-radius: 10px;
padding: 20px;
background: var(--secondary-color);
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.divPanelTitle {
font-size: 130%;
font-weight: bold;
margin-bottom: 10px;
}
.divPanelSubTitle {
font-size: 90%;
color: var(--primary-color);
margin-bottom: 15px;
}
.divGrid2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.divGrid3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.divField {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 10px;
}
.divFieldLabel {
font-size: 80%;
font-weight: bold;
}
.inText,
.inNumber,
.inSelect,
.taInput,
.taOutput {
width: 100%;
border: 1px solid var(--primary-color);
border-radius: 5px;
background: var(--secondary-color);
color: var(--primary-color);
padding: 10px;
font-size: 80%;
}
.taInput {
min-height: 120px;
resize: vertical;
}
.taOutput {
min-height: 120px;
background: var(--muted-color);
white-space: pre-wrap;
overflow: auto;
}
.taTall {
min-height: 180px;
}
.taXL {
min-height: 260px;
}
.divRow {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.btn {
border: 2px solid var(--primary-color);
border-radius: 5px;
background: var(--secondary-color);
color: var(--primary-color);
padding: 10px 15px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}
.btn:hover {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: var(--secondary-color);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.divStatusBadge {
display: inline-block;
border: 1px solid var(--primary-color);
border-radius: 5px;
padding: 5px 10px;
font-size: 75%;
font-weight: bold;
}
.divWarning {
border: 2px solid var(--accent-color);
border-radius: 5px;
padding: 10px;
font-size: 75%;
margin-bottom: 10px;
}
.divSummary {
border: 1px solid var(--primary-color);
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
font-size: 80%;
}
.divContextParts {
display: flex;
flex-direction: column;
gap: 10px;
}
.detPart {
border: 1px solid var(--primary-color);
border-radius: 5px;
padding: 10px;
background: var(--secondary-color);
}
.detPart summary {
cursor: pointer;
font-size: 80%;
font-weight: bold;
}
.prePart {
margin-top: 10px;
padding: 10px;
border: 1px solid var(--muted-color);
border-radius: 5px;
background: var(--secondary-color);
font-size: 75%;
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 260px;
overflow-y: auto;
}
.divKV {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
font-size: 80%;
}
.divKVItem {
border: 1px solid var(--primary-color);
border-radius: 5px;
padding: 10px;
min-height: 50px;
}
.spanMuted {
opacity: 0.7;
font-size: 90%;
}
.divCompareCols {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.divSectionFooter {
margin-top: 10px;
font-size: 70%;
opacity: 0.8;
}
@media (max-width: 1000px) {
.divGrid2,
.divGrid3,
.divKV,
.divCompareCols {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">DIDACTYL</div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================ -->
<div id="divBody">
<div class="divPanel">
<div class="divPanelTitle">Status Dashboard</div>
<div class="divPanelSubTitle">Agent health, runtime info, and API connectivity.</div>
<div class="divRow" style="margin-bottom: 10px;">
<div class="divField" style="min-width: 160px; margin-bottom: 0;">
<label class="divFieldLabel" for="apiProtocolInput">API Protocol</label>
<select id="apiProtocolInput" class="inSelect">
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
<div class="divField" style="min-width: 220px; margin-bottom: 0;">
<label class="divFieldLabel" for="apiHostInput">API Host</label>
<input id="apiHostInput" class="inText" type="text" value="127.0.0.1" />
</div>
<div class="divField" style="min-width: 160px; margin-bottom: 0;">
<label class="divFieldLabel" for="apiPortInput">API Port</label>
<input id="apiPortInput" class="inNumber" type="number" min="1" max="65535" value="8484" />
</div>
<button id="btnApplyApiAddress" class="btn">Apply API Address</button>
<button id="btnRefreshStatus" class="btn">Refresh Status</button>
</div>
<div class="divRow" style="margin-bottom: 10px;">
<span id="apiStatusBadge" class="divStatusBadge">API: Unknown</span>
</div>
<div id="statusGrid" class="divKV"></div>
<div class="divField" style="margin-top: 10px; margin-bottom: 0;">
<label class="divFieldLabel" for="apiDebugOutput">API Debug Log</label>
<textarea id="apiDebugOutput" class="taOutput taTall" readonly placeholder="Connection diagnostics will appear here."></textarea>
</div>
</div>
<div class="divPanel">
<div class="divPanelTitle">Context Inspector</div>
<div class="divPanelSubTitle">Inspect current LLM context parts with token estimates and content.</div>
<div class="divRow" style="margin-bottom: 10px;">
<button id="btnLoadContextParts" class="btn">Load Context Parts</button>
</div>
<div id="contextSummary" class="divSummary">No context loaded.</div>
<div id="contextPartsList" class="divContextParts"></div>
</div>
<div class="divPanel">
<div class="divPanelTitle">Simple Prompt Crafting</div>
<div class="divPanelSubTitle">Run a basic system + user prompt without tools.</div>
<div class="divGrid2">
<div class="divField">
<label class="divFieldLabel" for="simpleSystemPrompt">System Prompt</label>
<textarea id="simpleSystemPrompt" class="taInput taTall" placeholder="You are Didactyl, a concise assistant."></textarea>
</div>
<div class="divField">
<label class="divFieldLabel" for="simpleUserPrompt">User Message</label>
<textarea id="simpleUserPrompt" class="taInput taTall" placeholder="Summarize the latest context changes."></textarea>
</div>
</div>
<div class="divRow" style="margin-bottom: 10px;">
<div class="divField" style="min-width: 280px; margin-bottom: 0;">
<label class="divFieldLabel" for="simpleModelOverride">Model Override (optional)</label>
<input id="simpleModelOverride" class="inText" type="text" placeholder="e.g. claude-haiku-4.5" />
</div>
<button id="btnRunSimple" class="btn">Run Simple Prompt</button>
</div>
<div class="divGrid3" style="margin-bottom: 10px;">
<div id="simpleModelUsed" class="divKVItem">model_used: -</div>
<div id="simpleInputTokens" class="divKVItem">input_tokens_estimate: -</div>
<div id="simpleOutputTokens" class="divKVItem">output_tokens_estimate: -</div>
</div>
<textarea id="simpleResponse" class="taOutput" readonly placeholder="Simple response output will appear here."></textarea>
</div>
<div class="divPanel">
<div class="divPanelTitle">Full Prompt with Tools</div>
<div class="divPanelSubTitle">Edit the full messages array and run with tool execution enabled.</div>
<div class="divWarning">
Tool execution is real when calling <strong>/api/prompt/run</strong>; agent tool calls can perform real side effects.
</div>
<div class="divRow" style="margin-bottom: 10px;">
<button id="btnLoadContextMessages" class="btn">Load Current Context Into Messages</button>
</div>
<div class="divField">
<label class="divFieldLabel" for="fullMessagesJson">Messages JSON Array</label>
<textarea id="fullMessagesJson" class="taInput taXL" placeholder="[{"role":"system","content":"..."},{"role":"user","content":"..."}]"></textarea>
</div>
<div class="divRow" style="margin-bottom: 10px;">
<div class="divField" style="min-width: 200px; margin-bottom: 0;">
<label class="divFieldLabel" for="fullMaxTurns">Max Turns</label>
<input id="fullMaxTurns" class="inNumber" type="number" min="1" value="4" />
</div>
<div class="divField" style="min-width: 280px; margin-bottom: 0;">
<label class="divFieldLabel" for="fullModelOverride">Model Override (optional)</label>
<input id="fullModelOverride" class="inText" type="text" placeholder="e.g. claude-haiku-4.5" />
</div>
<label class="divRow" style="font-size: 80%;">
<input id="fullConfirmTools" type="checkbox" />
I understand this can execute real tools.
</label>
<button id="btnRunFull" class="btn">Run Full Prompt</button>
</div>
<div class="divGrid3" style="margin-bottom: 10px;">
<div id="fullModelUsed" class="divKVItem">model_used: -</div>
<div id="fullInputTokens" class="divKVItem">total_input_tokens_estimate: -</div>
<div id="fullOutputTokens" class="divKVItem">total_output_tokens_estimate: -</div>
</div>
<div class="divGrid2">
<div class="divField">
<label class="divFieldLabel" for="fullFinalResponse">Final Response</label>
<textarea id="fullFinalResponse" class="taOutput taTall" readonly placeholder="Final response will appear here."></textarea>
</div>
<div class="divField">
<label class="divFieldLabel" for="fullTraceOutput">Turn Trace / Tool Calls</label>
<textarea id="fullTraceOutput" class="taOutput taTall" readonly placeholder="Turn trace will appear here."></textarea>
</div>
</div>
</div>
<div class="divPanel">
<div class="divPanelTitle">A/B Prompt Comparison</div>
<div class="divPanelSubTitle">Run two prompt variants side-by-side and compare outputs.</div>
<div class="divWarning">
Comparison also executes tools for each variant when model behavior chooses tool calls.
</div>
<div class="divCompareCols" style="margin-bottom: 10px;">
<div>
<div class="divField">
<label class="divFieldLabel" for="compareMessagesA">Variant A Messages JSON</label>
<textarea id="compareMessagesA" class="taInput taTall" placeholder="[{"role":"system","content":"..."},{"role":"user","content":"..."}]"></textarea>
</div>
<div class="divRow">
<div class="divField" style="min-width: 160px; margin-bottom: 0;">
<label class="divFieldLabel" for="compareMaxTurnsA">Max Turns A</label>
<input id="compareMaxTurnsA" class="inNumber" type="number" min="1" value="4" />
</div>
<div class="divField" style="min-width: 220px; margin-bottom: 0;">
<label class="divFieldLabel" for="compareModelA">Model A (optional)</label>
<input id="compareModelA" class="inText" type="text" placeholder="optional model override" />
</div>
</div>
</div>
<div>
<div class="divField">
<label class="divFieldLabel" for="compareMessagesB">Variant B Messages JSON</label>
<textarea id="compareMessagesB" class="taInput taTall" placeholder="[{"role":"system","content":"..."},{"role":"user","content":"..."}]"></textarea>
</div>
<div class="divRow">
<div class="divField" style="min-width: 160px; margin-bottom: 0;">
<label class="divFieldLabel" for="compareMaxTurnsB">Max Turns B</label>
<input id="compareMaxTurnsB" class="inNumber" type="number" min="1" value="4" />
</div>
<div class="divField" style="min-width: 220px; margin-bottom: 0;">
<label class="divFieldLabel" for="compareModelB">Model B (optional)</label>
<input id="compareModelB" class="inText" type="text" placeholder="optional model override" />
</div>
</div>
</div>
</div>
<div class="divRow" style="margin-bottom: 10px;">
<button id="btnCompare" class="btn">Run A/B Compare</button>
</div>
<div class="divGrid2" style="margin-bottom: 10px;">
<div id="compareStatsA" class="divKVItem">Variant A stats: -</div>
<div id="compareStatsB" class="divKVItem">Variant B stats: -</div>
</div>
<div class="divCompareCols">
<div class="divField">
<label class="divFieldLabel" for="compareOutputA">Variant A Final Response</label>
<textarea id="compareOutputA" class="taOutput taTall" readonly placeholder="Variant A final response."></textarea>
</div>
<div class="divField">
<label class="divFieldLabel" for="compareOutputB">Variant B Final Response</label>
<textarea id="compareOutputB" class="taOutput taTall" readonly placeholder="Variant B final response."></textarea>
</div>
</div>
<textarea id="compareTrace" class="taOutput taTall" readonly placeholder="Combined comparison trace output."></textarea>
<div class="divSectionFooter">Difference view is text-first; copy results into external diff tooling if you need strict semantic diffs.</div>
</div>
</div>
<!-- ================================================================
FOOTER
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
</div>
<!-- ================================================================
SIDENAV
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[didactyl.html ${VERSION}] Loading...`);
const DEFAULT_API_PROTOCOL = 'http';
const DEFAULT_API_HOST = '127.0.0.1';
const DEFAULT_API_PORT = '8484';
let apiProtocol = DEFAULT_API_PROTOCOL;
let apiHost = DEFAULT_API_HOST;
let apiPort = DEFAULT_API_PORT;
let API_BASE_URL = `${apiProtocol}://${apiHost}:${apiPort}`;
let updateIntervalId = null;
let currentPubkey = null;
let hamburgerInstance = null;
let isNavOpen = false;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const apiStatusBadge = document.getElementById('apiStatusBadge');
const statusGrid = document.getElementById('statusGrid');
const apiProtocolInput = document.getElementById('apiProtocolInput');
const apiHostInput = document.getElementById('apiHostInput');
const apiPortInput = document.getElementById('apiPortInput');
const btnApplyApiAddress = document.getElementById('btnApplyApiAddress');
const contextSummary = document.getElementById('contextSummary');
const contextPartsList = document.getElementById('contextPartsList');
const simpleSystemPrompt = document.getElementById('simpleSystemPrompt');
const simpleUserPrompt = document.getElementById('simpleUserPrompt');
const simpleModelOverride = document.getElementById('simpleModelOverride');
const simpleResponse = document.getElementById('simpleResponse');
const simpleModelUsed = document.getElementById('simpleModelUsed');
const simpleInputTokens = document.getElementById('simpleInputTokens');
const simpleOutputTokens = document.getElementById('simpleOutputTokens');
const fullMessagesJson = document.getElementById('fullMessagesJson');
const fullMaxTurns = document.getElementById('fullMaxTurns');
const fullModelOverride = document.getElementById('fullModelOverride');
const fullConfirmTools = document.getElementById('fullConfirmTools');
const fullFinalResponse = document.getElementById('fullFinalResponse');
const fullTraceOutput = document.getElementById('fullTraceOutput');
const fullModelUsed = document.getElementById('fullModelUsed');
const fullInputTokens = document.getElementById('fullInputTokens');
const fullOutputTokens = document.getElementById('fullOutputTokens');
const compareMessagesA = document.getElementById('compareMessagesA');
const compareModelA = document.getElementById('compareModelA');
const compareMaxTurnsA = document.getElementById('compareMaxTurnsA');
const compareMessagesB = document.getElementById('compareMessagesB');
const compareModelB = document.getElementById('compareModelB');
const compareMaxTurnsB = document.getElementById('compareMaxTurnsB');
const compareStatsA = document.getElementById('compareStatsA');
const compareStatsB = document.getElementById('compareStatsB');
const compareOutputA = document.getElementById('compareOutputA');
const compareOutputB = document.getElementById('compareOutputB');
const compareTrace = document.getElementById('compareTrace');
const apiDebugOutput = document.getElementById('apiDebugOutput');
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "50vw";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = `
<div style="padding: 0 20px 20px 20px; display: flex; flex-direction: column; gap: 10px; font-size: 80%;">
<div style="font-weight: bold;">Didactyl Admin</div>
<div>API: <span id="sideApiBase">${API_BASE_URL}</span></div>
<div>Use this panel to inspect status, context, and prompt outputs.</div>
<a href="./didactyl.html">didactyl.html</a>
<a href="./relays.html">relays.html</a>
<a href="./tools.html">tools.html</a>
</div>
`;
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
function setApiStatus(isUp, text = '') {
if (isUp) {
apiStatusBadge.textContent = `API: Connected ${text}`.trim();
apiStatusBadge.style.borderColor = 'var(--primary-color)';
} else {
apiStatusBadge.textContent = `API: Offline ${text}`.trim();
apiStatusBadge.style.borderColor = 'var(--accent-color)';
}
}
function appendApiDebug(message, details = null) {
const timestamp = new Date().toISOString();
const detailText = details ? `\n${JSON.stringify(details, null, 2)}` : '';
const line = `[${timestamp}] ${message}${detailText}`;
if (apiDebugOutput) {
const existing = apiDebugOutput.value ? `${apiDebugOutput.value}\n\n` : '';
apiDebugOutput.value = `${existing}${line}`;
apiDebugOutput.scrollTop = apiDebugOutput.scrollHeight;
}
console.log('[didactyl.html][api-debug]', message, details || '');
}
function applyApiAddressFromInputs() {
const protocolRaw = (apiProtocolInput?.value || '').trim().toLowerCase() || DEFAULT_API_PROTOCOL;
const host = (apiHostInput?.value || '').trim() || DEFAULT_API_HOST;
const portRaw = (apiPortInput?.value || '').trim() || DEFAULT_API_PORT;
const port = Number(portRaw);
if (protocolRaw !== 'http' && protocolRaw !== 'https') {
throw new Error('API protocol must be http or https');
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('API port must be an integer between 1 and 65535');
}
apiProtocol = protocolRaw;
apiHost = host;
apiPort = String(port);
API_BASE_URL = `${apiProtocol}://${apiHost}:${apiPort}`;
const sideApiBase = document.getElementById('sideApiBase');
if (sideApiBase) {
sideApiBase.textContent = API_BASE_URL;
}
appendApiDebug('Applied API address', {
apiBaseUrl: API_BASE_URL,
protocol: apiProtocol,
host: apiHost,
port: apiPort
});
}
async function apiRequest(path, options = {}) {
const method = options.method || 'GET';
const url = `${API_BASE_URL}${path}`;
const startedAt = Date.now();
appendApiDebug('Request start', {
method,
url,
pageOrigin: window.location.origin,
pageProtocol: window.location.protocol,
online: navigator.onLine,
isSecureContext: window.isSecureContext
});
let response;
try {
response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json'
},
body: options.body ? JSON.stringify(options.body) : undefined
});
} catch (networkError) {
appendApiDebug('Network error during fetch', {
method,
url,
name: networkError?.name,
message: networkError?.message,
stack: networkError?.stack
});
const lowerMsg = String(networkError?.message || '').toLowerCase();
const hint = lowerMsg.includes('access')
? 'Browser/network policy blocked localhost access (ERR_ACCESS_DENIED); check browser security settings, extension policy, or localhost allowlist.'
: 'Fetch failed before HTTP response; verify API is running and browser can access the target host/port.';
throw new Error(`${networkError?.message || 'Network error'} | ${hint}`);
}
appendApiDebug('Response received', {
method,
url,
status: response.status,
ok: response.ok,
elapsedMs: Date.now() - startedAt
});
let data = null;
try {
data = await response.json();
} catch {
appendApiDebug('Response JSON parse failed', {
method,
url,
status: response.status
});
data = { success: false, error: `Invalid JSON response from ${path}` };
}
if (!response.ok || !data || data.success === false) {
const errorMessage = data?.error || `HTTP ${response.status} on ${path}`;
appendApiDebug('Request failed', {
method,
url,
error: errorMessage,
response: data
});
throw new Error(errorMessage);
}
appendApiDebug('Request success', {
method,
url,
elapsedMs: Date.now() - startedAt
});
return data;
}
function formatNumber(value) {
if (value === null || value === undefined) {
return '-';
}
return Number(value).toLocaleString();
}
function setTextAreaValue(el, value) {
el.value = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function parseMessagesJson(raw) {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('Messages must be a JSON array.');
}
parsed.forEach((msg, index) => {
if (typeof msg !== 'object' || msg === null) {
throw new Error(`Message at index ${index} must be an object.`);
}
if (!msg.role || !msg.content) {
throw new Error(`Message at index ${index} must contain role and content.`);
}
});
return parsed;
}
function formatTurns(turns = []) {
if (!Array.isArray(turns) || turns.length === 0) {
return 'No turn trace available.';
}
return turns.map((turn) => {
const lines = [`Turn ${turn.turn ?? '?'}:`];
if (Array.isArray(turn.tool_calls) && turn.tool_calls.length > 0) {
lines.push(` tool_calls: ${turn.tool_calls.length}`);
turn.tool_calls.forEach((toolCall, idx) => {
lines.push(` ${idx + 1}. ${toolCall.name || 'unknown_tool'}`);
lines.push(` arguments: ${typeof toolCall.arguments === 'string' ? toolCall.arguments : JSON.stringify(toolCall.arguments)}`);
lines.push(` result: ${typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result)}`);
});
} else {
lines.push(' tool_calls: 0');
}
return lines.join('\n');
}).join('\n\n');
}
function renderStatus(statusData) {
const rows = [
['name', statusData.name],
['version', statusData.version],
['pubkey', statusData.pubkey],
['relay_count', formatNumber(statusData.relay_count)],
['connected_relays', formatNumber(statusData.connected_relays)],
['active_triggers', formatNumber(statusData.active_triggers)]
];
statusGrid.innerHTML = rows.map(([k, v]) => `
<div class="divKVItem"><span class="spanMuted">${k}</span><br>${v ?? '-'}</div>
`).join('');
}
function renderContextParts(data) {
contextSummary.innerHTML = `
<strong>Total chars:</strong> ${formatNumber(data.total_chars)}
&nbsp;|&nbsp;
<strong>Total estimated tokens:</strong> ${formatNumber(data.total_estimated_tokens)}
&nbsp;|&nbsp;
<strong>Parts:</strong> ${Array.isArray(data.parts) ? data.parts.length : 0}
`;
if (!Array.isArray(data.parts) || data.parts.length === 0) {
contextPartsList.innerHTML = '<div class="divSummary">No context parts returned.</div>';
return;
}
contextPartsList.innerHTML = data.parts.map((part, index) => `
<details class="detPart">
<summary>
${index + 1}. ${part.name || 'unnamed'}
<span class="spanMuted">(role: ${part.role || '-'}, chars: ${formatNumber(part.chars)}, tokens: ${formatNumber(part.estimated_tokens)})</span>
</summary>
<pre class="prePart">${(part.content || '').replace(/</g, '<').replace(/>/g, '>')}</pre>
</details>
`).join('');
}
async function refreshStatus() {
try {
const status = await apiRequest('/api/status');
setApiStatus(true);
renderStatus(status);
divFooterCenter.textContent = `Didactyl ${status.version || ''}`.trim();
divFooterRight.textContent = `Relays ${formatNumber(status.connected_relays)}/${formatNumber(status.relay_count)}`;
} catch (error) {
setApiStatus(false, `(${error.message})`);
statusGrid.innerHTML = `<div class="divKVItem">Status request failed: ${error.message}</div>`;
divFooterCenter.textContent = 'Didactyl API offline';
divFooterRight.textContent = '';
appendApiDebug('refreshStatus failed', {
apiBaseUrl: API_BASE_URL,
error: error.message
});
}
}
async function loadContextParts() {
contextSummary.textContent = 'Loading context...';
contextPartsList.innerHTML = '';
try {
const data = await apiRequest('/api/context/parts');
setApiStatus(true);
renderContextParts(data);
} catch (error) {
setApiStatus(false, `(${error.message})`);
contextSummary.textContent = `Failed to load context: ${error.message}`;
}
}
async function loadContextIntoMessages() {
try {
const data = await apiRequest('/api/context/current');
setApiStatus(true);
setTextAreaValue(fullMessagesJson, data.messages || []);
} catch (error) {
setApiStatus(false, `(${error.message})`);
fullTraceOutput.value = `Failed to load context/current: ${error.message}`;
}
}
async function runSimplePrompt() {
simpleResponse.value = 'Running simple prompt...';
simpleModelUsed.textContent = 'model_used: -';
simpleInputTokens.textContent = 'input_tokens_estimate: -';
simpleOutputTokens.textContent = 'output_tokens_estimate: -';
try {
const payload = {
system_prompt: simpleSystemPrompt.value || '',
user_message: simpleUserPrompt.value || ''
};
const model = (simpleModelOverride.value || '').trim();
if (model) {
payload.model = model;
}
const data = await apiRequest('/api/prompt/run-simple', {
method: 'POST',
body: payload
});
setApiStatus(true);
simpleResponse.value = data.response || '';
simpleModelUsed.textContent = `model_used: ${data.model_used || '-'}`;
simpleInputTokens.textContent = `input_tokens_estimate: ${formatNumber(data.input_tokens_estimate)}`;
simpleOutputTokens.textContent = `output_tokens_estimate: ${formatNumber(data.output_tokens_estimate)}`;
} catch (error) {
setApiStatus(false, `(${error.message})`);
simpleResponse.value = `Simple prompt failed: ${error.message}`;
}
}
async function runFullPrompt() {
fullFinalResponse.value = '';
fullTraceOutput.value = 'Running full prompt...';
if (!fullConfirmTools.checked) {
fullTraceOutput.value = 'You must confirm real tool execution before running full prompt.';
return;
}
try {
const messages = parseMessagesJson(fullMessagesJson.value || '[]');
const payload = {
messages,
max_turns: Number(fullMaxTurns.value || 4)
};
const model = (fullModelOverride.value || '').trim();
if (model) {
payload.model = model;
}
const data = await apiRequest('/api/prompt/run', {
method: 'POST',
body: payload
});
setApiStatus(true);
fullFinalResponse.value = data.final_response || '';
fullTraceOutput.value = formatTurns(data.turns || []);
fullModelUsed.textContent = `model_used: ${data.model_used || '-'}`;
fullInputTokens.textContent = `total_input_tokens_estimate: ${formatNumber(data.total_input_tokens_estimate)}`;
fullOutputTokens.textContent = `total_output_tokens_estimate: ${formatNumber(data.total_output_tokens_estimate)}`;
} catch (error) {
setApiStatus(false, `(${error.message})`);
fullTraceOutput.value = `Full prompt failed: ${error.message}`;
}
}
function createCompareVariant(messagesRaw, maxTurnsRaw, modelRaw) {
const variant = {
messages: parseMessagesJson(messagesRaw || '[]'),
max_turns: Number(maxTurnsRaw || 4)
};
const model = (modelRaw || '').trim();
if (model) {
variant.model = model;
}
return variant;
}
function renderCompareVariantStats(container, label, variant) {
container.textContent = `${label}: model=${variant.model_used || '-'}, input=${formatNumber(variant.total_input_tokens_estimate)}, output=${formatNumber(variant.total_output_tokens_estimate)}`;
}
async function runComparePrompt() {
compareOutputA.value = '';
compareOutputB.value = '';
compareTrace.value = 'Running comparison...';
try {
const payload = {
variant_a: createCompareVariant(compareMessagesA.value, compareMaxTurnsA.value, compareModelA.value),
variant_b: createCompareVariant(compareMessagesB.value, compareMaxTurnsB.value, compareModelB.value)
};
const data = await apiRequest('/api/prompt/compare', {
method: 'POST',
body: payload
});
setApiStatus(true);
const variantA = data.variant_a || {};
const variantB = data.variant_b || {};
compareOutputA.value = variantA.final_response || '';
compareOutputB.value = variantB.final_response || '';
renderCompareVariantStats(compareStatsA, 'Variant A stats', variantA);
renderCompareVariantStats(compareStatsB, 'Variant B stats', variantB);
compareTrace.value = [
'=== Variant A Trace ===',
formatTurns(variantA.turns || []),
'',
'=== Variant B Trace ===',
formatTurns(variantB.turns || [])
].join('\n');
} catch (error) {
setApiStatus(false, `(${error.message})`);
compareTrace.value = `Compare failed: ${error.message}`;
}
}
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
} catch (error) {
console.error('[didactyl.html] Error updating footer:', error);
}
};
const Logout = async () => {
console.log("[didactyl.html] Starting logout process...");
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[didactyl.html] Logged out, reloading page");
location.reload(true);
};
function bindUiEvents() {
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
if (btnApplyApiAddress) {
btnApplyApiAddress.addEventListener('click', async () => {
try {
applyApiAddressFromInputs();
await refreshStatus();
} catch (error) {
setApiStatus(false, `(${error.message})`);
}
});
}
document.getElementById('btnRefreshStatus').addEventListener('click', refreshStatus);
document.getElementById('btnLoadContextParts').addEventListener('click', loadContextParts);
document.getElementById('btnRunSimple').addEventListener('click', runSimplePrompt);
document.getElementById('btnLoadContextMessages').addEventListener('click', loadContextIntoMessages);
document.getElementById('btnRunFull').addEventListener('click', runFullPrompt);
document.getElementById('btnCompare').addEventListener('click', runComparePrompt);
}
function seedDefaultPromptEditors() {
if (!simpleSystemPrompt.value.trim()) {
simpleSystemPrompt.value = 'You are Didactyl; answer with concise, high-signal operational guidance.';
}
if (!simpleUserPrompt.value.trim()) {
simpleUserPrompt.value = 'Summarize the currently loaded context in 5 bullet points.';
}
if (!fullMessagesJson.value.trim()) {
fullMessagesJson.value = JSON.stringify([
{
role: 'system',
content: 'You are Didactyl with tools enabled; reason carefully before choosing tools.'
},
{
role: 'user',
content: 'Inspect context and propose the safest next admin action.'
}
], null, 2);
}
if (!compareMessagesA.value.trim()) {
compareMessagesA.value = JSON.stringify([
{
role: 'system',
content: 'You are Didactyl; respond with short, direct recommendations.'
},
{
role: 'user',
content: 'Propose one safe action and one high-impact action.'
}
], null, 2);
}
if (!compareMessagesB.value.trim()) {
compareMessagesB.value = JSON.stringify([
{
role: 'system',
content: 'You are Didactyl; include explicit risk analysis and confidence levels.'
},
{
role: 'user',
content: 'Propose one safe action and one high-impact action.'
}
], null, 2);
}
}
(async function main() {
console.log("[didactyl.html] Starting initialization...");
try {
initHamburgerMenu();
bindUiEvents();
seedDefaultPromptEditors();
if (apiProtocolInput && !apiProtocolInput.value.trim()) {
apiProtocolInput.value = DEFAULT_API_PROTOCOL;
}
if (apiHostInput && !apiHostInput.value.trim()) {
apiHostInput.value = DEFAULT_API_HOST;
}
if (apiPortInput && !apiPortInput.value.trim()) {
apiPortInput.value = DEFAULT_API_PORT;
}
applyApiAddressFromInputs();
await initNDKPage();
currentPubkey = await getPubkey();
console.log("[didactyl.html] Authenticated as:", currentPubkey);
initFooterRelayStatus();
initSidenavRelaySection();
await UpdateFooter();
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[didactyl.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[didactyl.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
updateIntervalId = setInterval(UpdateFooter, 1000);
await updateVersionDisplay();
await refreshStatus();
console.log('[didactyl.html] Initialization complete');
} catch (error) {
console.error('[didactyl.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Initialization Error</div>
<div style="font-size: 16px; color: var(--primary-color);">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
</script>
</body>
</html>