Files
client/www/html-tv.html

2830 lines
99 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>HTML TV</title>
<link rel="stylesheet" href="./css/client.css" />
<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" />
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/marked.min.js"></script>
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script src="./js/vendor/purify.min.js"></script>
<style>
#divBody {
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
align-content: stretch !important;
overflow: hidden;
padding: 10px;
gap: 10px;
}
#divAiLayout {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
min-height: 0;
gap: 10px;
}
#divAiConversationsPane {
width: 30%;
min-width: 230px;
max-width: 360px;
border: 2px solid var(--primary-color);
border-radius: 10px;
background: var(--secondary-color);
display: flex;
flex-direction: column;
min-height: 0;
gap: 8px;
padding: 10px;
}
#divAiConversationsTitle {
font-size: 90%;
font-weight: bold;
color: var(--primary-color);
border-bottom: 1px solid var(--muted-color);
padding-bottom: 8px;
}
#divAiConversationsList {
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.aiConversationItem {
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 8px;
cursor: pointer;
color: var(--primary-color);
transition: border-color 0.2s, color 0.2s;
background: var(--secondary-color);
}
.aiConversationItem:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
.aiConversationItem.active {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color) inset;
}
.aiConversationHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.aiConversationTitle {
font-size: 82%;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.aiDeleteConvBtn {
border: 1px solid var(--muted-color);
border-radius: 6px;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
background: var(--secondary-color);
color: var(--muted-color);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: auto;
}
.aiDeleteConvBtn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.aiDeleteConvBtn:hover {
color: var(--accent-color);
border-color: var(--accent-color);
}
.aiConversationPreview {
margin-top: 4px;
font-size: 70%;
color: var(--muted-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aiPageActions {
margin-top: 6px;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.aiPageActionBtn,
.aiPageLaunchLink {
border: 1px solid var(--muted-color);
border-radius: 6px;
padding: 2px 8px;
font-size: 68%;
color: var(--primary-color);
background: var(--secondary-color);
text-decoration: none;
cursor: pointer;
}
.aiPageActionBtn:hover,
.aiPageLaunchLink:hover {
color: var(--accent-color);
border-color: var(--accent-color);
}
.aiPageActionBtn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
#divAiChatPane {
flex: 1;
border: 2px solid var(--primary-color);
border-radius: 10px;
background: var(--secondary-color);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
#divAiChatHeader {
border-bottom: 1px solid var(--muted-color);
padding: 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--primary-color);
}
.aiSelect {
border: 1px solid var(--muted-color);
border-radius: 6px;
padding: 6px;
background: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
min-width: 220px;
max-width: 100%;
}
.aiDropdown {
position: relative;
min-width: 220px;
max-width: 100%;
width: 280px;
}
.aiDropdownBtn {
width: 100%;
border: 1px solid var(--muted-color);
border-radius: 6px;
padding: 6px;
background: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aiDropdownPanel {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
border: 1px solid var(--muted-color);
border-radius: 6px;
background: var(--secondary-color);
padding: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
}
.aiDropdown.open .aiDropdownPanel {
display: block;
}
.aiDropdownFilter {
width: 100%;
margin-bottom: 6px;
box-sizing: border-box;
}
.aiDropdownOptions {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.aiDropdownOption {
border: 1px solid transparent;
border-radius: 5px;
padding: 5px 6px;
cursor: pointer;
color: var(--primary-color);
font-size: 85%;
}
.aiDropdownOption:hover,
.aiDropdownOption.active {
border-color: var(--accent-color);
color: var(--accent-color);
}
.aiDropdownOption.favorite::before {
content: '★ ';
color: var(--accent-color);
}
.aiDropdownHint {
color: var(--muted-color);
font-size: 75%;
padding: 4px 2px;
}
#divAiHeaderStats {
margin-left: auto;
font-size: 76%;
color: var(--primary-color);
text-align: right;
display: flex;
flex-direction: column;
gap: 2px;
}
#divAiMessages {
flex: 1;
min-height: 0;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
#divAiViewerWrap {
position: relative;
flex: 1;
min-height: 0;
border: 1px solid var(--muted-color);
border-radius: 10px;
overflow: hidden;
background: #ffffff;
}
#iframeAiTv {
width: 100%;
height: 100%;
border: none;
display: block;
background: #ffffff;
}
#divAiViewerLoading {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--secondary-color) 80%, transparent 20%);
color: var(--primary-color);
font-size: 82%;
z-index: 5;
backdrop-filter: blur(2px);
}
#divAiViewerLoading.active {
display: flex;
}
#btnAiPopoutPreviewOverlay {
position: absolute;
top: 8px;
right: 8px;
z-index: 6;
width: 30px;
height: 30px;
border: 1px solid var(--muted-color);
border-radius: 6px;
background: var(--muted-color);
color: var(--secondary-color);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0.9;
}
#btnAiPopoutPreviewOverlay:hover {
opacity: 1;
}
#divAiInputArea {
border-top: 1px solid var(--muted-color);
padding: 10px;
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 10px;
transition: box-shadow 0.2s ease;
}
#divAiInputArea.dragover {
box-shadow: 0 0 0 2px var(--accent-color) inset;
}
#divAiPromptColumn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
#taAiPrompt {
flex: 1;
min-height: 104px;
max-height: 360px;
resize: vertical;
border: 1px solid var(--primary-color);
border-radius: 8px;
padding: 8px;
background: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 90%;
}
#btnAiRun,
#btnAiSaveSkill {
min-width: 140px;
}
#divSkillFilterTabs {
display: flex;
gap: 6px;
}
.skillFilterBtn {
flex: 1;
font-size: 75%;
padding: 6px;
color: var(--muted-color);
border-color: var(--muted-color);
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}
.skillFilterBtn.active {
color: var(--primary-color);
border-color: var(--primary-color);
background: var(--secondary-color);
}
#divSkillSidebarActions {
display: flex;
flex-direction: column;
gap: 6px;
}
.aiSkillRow2 {
display: grid;
grid-template-columns: 180px 1fr;
gap: 8px;
}
.aiSkillRow3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.aiSkillControlsRow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
align-items: end;
}
.aiSkillInlineField {
display: flex;
flex-direction: column;
gap: 4px;
}
.aiSkillInlineField label {
font-size: 72%;
color: var(--muted-color);
}
#taSkillTemplate {
min-height: 160px;
font-size: 82%;
line-height: 1.4;
}
.aiTemplateDetails {
border: 1px solid var(--muted-color);
border-radius: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--secondary-color) 92%, var(--muted-color) 8%);
}
.aiTemplateDetails summary {
cursor: pointer;
font-size: 72%;
color: var(--muted-color);
user-select: none;
}
.aiTemplateDetails[open] summary {
margin-bottom: 6px;
}
#divAiRenderedPageActions {
padding: 0 10px 10px 10px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
#lnkAiLaunchRenderedPage {
display: inline-flex;
align-items: center;
text-decoration: none;
}
#divAiStatus {
font-size: 75%;
color: var(--muted-color);
padding: 0 10px 8px 10px;
min-height: 16px;
}
.aiSideSection {
border-top: 1px solid var(--muted-color);
margin-top: 10px;
padding-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.aiActionGroup {
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
background: color-mix(in srgb, var(--secondary-color) 85%, var(--muted-color) 15%);
}
.aiSideLabel {
font-size: 75%;
color: var(--muted-color);
}
.aiInput,
.aiTextarea {
border: 1px solid var(--muted-color);
border-radius: 6px;
background: var(--secondary-color);
color: var(--primary-color);
padding: 8px;
font-family: var(--font-family);
font-size: 85%;
width: 100%;
box-sizing: border-box;
}
.aiTextarea {
min-height: 90px;
resize: vertical;
}
.aiInvoicePanel {
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
background: color-mix(in srgb, var(--secondary-color) 88%, var(--muted-color) 12%);
}
#divRoutstrInvoiceQr {
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
border: 1px dashed var(--muted-color);
border-radius: 8px;
padding: 8px;
}
#taRoutstrInvoiceBolt11 {
min-height: 74px;
font-size: 76%;
word-break: break-all;
}
#divAiPageLinks {
display: flex;
flex-direction: column;
gap: 6px;
}
#divAiPageLinks a {
color: var(--primary-color);
text-decoration: none;
font-size: 80%;
border: 1px solid var(--muted-color);
border-radius: 6px;
padding: 6px;
display: block;
}
#divAiPageLinks a:hover {
color: var(--accent-color);
border-color: var(--accent-color);
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">HTML TV</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divAiLayout">
<div id="divAiConversationsPane">
<div id="divAiConversationsTitle">Skills</div>
<div id="divSkillFilterTabs">
<button id="btnSkillFilterAll" class="btn skillFilterBtn active" type="button">All</button>
<button id="btnSkillFilterMy" class="btn skillFilterBtn" type="button">My</button>
</div>
<div id="divAiConversationsList"></div>
<div id="divSkillSidebarActions">
<button id="btnSkillNew" class="btn" type="button">+ New Skill</button>
<button id="btnSkillDelete" class="btn" type="button">Delete Skill</button>
<button id="btnSkillRefresh" class="btn" type="button">Refresh</button>
</div>
</div>
<div id="divAiChatPane">
<div id="divAiChatHeader">
<select id="selAiProvider" class="aiSelect"></select>
<div id="ddAiModel" class="aiDropdown">
<button id="btnAiModelDropdown" class="aiDropdownBtn" type="button">Model</button>
<div class="aiDropdownPanel">
<input id="inpAiModelFilter" class="aiInput aiDropdownFilter" placeholder="filter models..." />
<div id="divAiModelOptions" class="aiDropdownOptions"></div>
</div>
</div>
<select id="selAiModel" class="aiSelect" style="display:none;"></select>
<div id="divAiHeaderStats">
<div id="divAiProviderBalanceHeader">--</div>
</div>
</div>
<div id="divAiMessages">
<div id="divAiViewerWrap">
<iframe id="iframeAiTv" sandbox="allow-scripts" title="AI TV Viewer"></iframe>
<div id="divAiViewerLoading">Generating page...</div>
<button id="btnAiPopoutPreviewOverlay" type="button" aria-label="Pop out preview" title="Pop out preview"></button>
</div>
</div>
<div id="divAiInputArea">
<div id="divAiPromptColumn">
<div class="aiSkillRow2">
<div class="aiSkillInlineField">
<label for="inpSkillSlug">Slug (d tag)</label>
<input id="inpSkillSlug" class="aiInput" type="text" placeholder="skill-slug" />
</div>
<div class="aiSkillInlineField">
<label for="inpSkillDescription">Description</label>
<input id="inpSkillDescription" class="aiInput" type="text" placeholder="Skill description" />
</div>
</div>
<div class="aiSkillControlsRow">
<div class="aiSkillInlineField">
<label for="inpSkillTemperature">Temp</label>
<input id="inpSkillTemperature" class="aiInput" type="number" min="0" max="2" step="0.1" placeholder="0" />
</div>
<div class="aiSkillInlineField">
<label for="inpSkillMaxTokens">Tokens</label>
<input id="inpSkillMaxTokens" class="aiInput" type="number" min="100" step="1" placeholder="4000" />
</div>
<div class="aiSkillInlineField">
<label for="inpSkillSeed">Seed</label>
<input id="inpSkillSeed" class="aiInput" type="number" step="1" placeholder="optional" />
</div>
</div>
<details id="detailsSkillTemplate" class="aiTemplateDetails">
<summary>Template</summary>
<textarea id="taSkillTemplate" class="aiTextarea" placeholder="system:\nYou are a web page generator...\n\nuser:\n{{message}}"></textarea>
</details>
<div class="aiSkillInlineField">
<label for="taAiPrompt">Message</label>
<textarea id="taAiPrompt" placeholder="User input for {{message}}..."></textarea>
</div>
<textarea id="taAiSystemPrompt" class="aiTextarea" style="display:none;"></textarea>
<div id="divAiPendingAttachments" style="display:none;"></div>
<input id="inpAiImageUpload" type="file" accept="image/*" multiple style="display:none;" />
</div>
</div>
<div id="divAiRenderedPageActions">
<button id="btnAiRun" class="btn" type="button">Run Skill</button>
<button id="btnAiSaveSkill" class="btn" type="button">Save Skill</button>
<button id="btnAiSaveRenderedPage" class="btn" type="button" disabled>Save Rendered Page</button>
<a id="lnkAiLaunchRenderedPage" class="btn" href="#" target="_blank" rel="noopener noreferrer" style="display:none;">Launch Rendered Page</a>
</div>
<div id="divAiStatus"></div>
</div>
</div>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div class="aiSideSection">
<div class="aiSideLabel">OpenAI-Compatible Config</div>
<div class="aiConfigField">
<label for="selCfgProvider">Provider</label>
<select id="selCfgProvider" class="aiInput"></select>
</div>
<div class="aiConfigField">
<label for="inpCfgBaseUrl">Base URL</label>
<input id="inpCfgBaseUrl" class="aiInput" placeholder="https://api.ppq.ai" />
</div>
<div id="divPpqCreditIdGroup" class="aiConfigField" style="display: none;">
<label for="inpCfgCreditId">Credit ID</label>
<input id="inpCfgCreditId" class="aiInput" type="text" placeholder="credit id" />
</div>
<div class="aiConfigField">
<label for="inpCfgApiKey">API Key / Cashu Token</label>
<div style="display: flex; gap: 4px; width: 100%;">
<input id="inpCfgApiKey" class="aiInput" type="text" placeholder="sk-... or cashuA..." style="flex-grow: 1;" />
<button id="btnCfgCopyApiKey" class="btn" style="width: 60px; padding: 0;">Copy</button>
</div>
</div>
<div class="aiConfigField">
<label for="selCfgModel">Model</label>
<div style="display: flex; gap: 4px; width: 100%; align-items: flex-start;">
<div id="ddCfgModel" class="aiDropdown" style="flex-grow: 1; width: auto; min-width: 0;">
<button id="btnCfgModelDropdown" class="aiDropdownBtn" type="button">Model</button>
<div class="aiDropdownPanel">
<input id="inpCfgModelFilter" class="aiInput aiDropdownFilter" placeholder="filter models..." />
<div id="divCfgModelOptions" class="aiDropdownOptions"></div>
</div>
</div>
<select id="selCfgModel" class="aiInput" style="display:none;"></select>
<button id="btnCfgFav" class="btn" style="width: 32px; padding: 0;"></button>
</div>
</div>
<div class="aiConfigField">
<label for="inpCfgMaxTokens">Max Tokens</label>
<input id="inpCfgMaxTokens" class="aiInput" type="number" min="1" step="1" placeholder="512" />
</div>
<div class="aiConfigField">
<label for="inpCfgTemperature">Temp</label>
<input id="inpCfgTemperature" class="aiInput" type="number" min="0" max="2" step="0.1" placeholder="0.7" />
</div>
<button id="btnCfgSave" class="btn" style="width: 100%;">Save Config</button>
</div>
<div class="aiSideSection">
<div class="aiSideLabel">System Prompt (per conversation)</div>
<textarea id="taAiSystemPrompt" class="aiTextarea">You are a helpful assistant.</textarea>
</div>
<div class="aiSideSection">
<div class="aiSideLabel">Payment Methods</div>
<div class="aiActionGroup">
<div class="aiSideLabel">Balance</div>
<button id="btnRoutstrGetBalance" class="btn" style="width: 100%;">Get Balance</button>
<div id="divRoutstrBalanceState" class="aiConversationPreview" style="white-space: pre-wrap;">No balance loaded.</div>
</div>
<div class="aiActionGroup">
<div class="aiConfigField">
<label for="inpRoutstrDepositSats">Deposit (Lightning sats)</label>
<input id="inpRoutstrDepositSats" class="aiInput" type="number" min="1" step="1" value="5000" />
</div>
<button id="btnRoutstrCreateInvoice" class="btn" style="width: 100%;">Create Deposit Invoice</button>
<div id="divRoutstrDepositInvoicePanel" class="aiInvoicePanel" style="margin-top: 8px;">
<div id="divRoutstrDepositInvoiceMeta" class="aiConversationPreview" style="white-space: pre-wrap;">No deposit invoice generated yet.</div>
<div id="divRoutstrDepositInvoiceStatus" class="aiConversationPreview" style="white-space: pre-wrap;">Idle.</div>
<div id="divRoutstrDepositInvoiceQr"></div>
<textarea id="taRoutstrDepositInvoiceBolt11" class="aiTextarea" readonly placeholder="deposit bolt11 invoice appears here"></textarea>
</div>
</div>
<div id="divRoutstrOnlySections" style="display: none;">
<div class="aiActionGroup">
<div class="aiConfigField">
<label for="inpRoutstrDepositCashu">Deposit (Cashu token)</label>
<textarea id="inpRoutstrDepositCashu" class="aiTextarea" placeholder="cashuA..."></textarea>
</div>
<button id="btnRoutstrImportCashu" class="btn" style="width: 100%;">Import Cashu as API Key</button>
</div>
<div class="aiActionGroup">
<div class="aiConfigField">
<label for="inpRoutstrTopupSats">Top-up (Lightning sats)</label>
<input id="inpRoutstrTopupSats" class="aiInput" type="number" min="1" step="1" value="1000" />
</div>
<button id="btnRoutstrTopupInvoice" class="btn" style="width: 100%;">Create Top-up Invoice</button>
<div id="divRoutstrTopupInvoicePanel" class="aiInvoicePanel" style="margin-top: 8px;">
<div id="divRoutstrTopupInvoiceMeta" class="aiConversationPreview" style="white-space: pre-wrap;">No top-up invoice generated yet.</div>
<div id="divRoutstrTopupInvoiceStatus" class="aiConversationPreview" style="white-space: pre-wrap;">Idle.</div>
<div id="divRoutstrTopupInvoiceQr"></div>
<textarea id="taRoutstrTopupInvoiceBolt11" class="aiTextarea" readonly placeholder="top-up bolt11 invoice appears here"></textarea>
</div>
</div>
<div class="aiActionGroup">
<div class="aiConfigField">
<label for="inpRoutstrTopupCashu">Top-up (Cashu token)</label>
<textarea id="inpRoutstrTopupCashu" class="aiTextarea" placeholder="cashuA..."></textarea>
</div>
<button id="btnRoutstrTopupCashu" class="btn" style="width: 100%;">Top-up with Cashu</button>
</div>
<div class="aiActionGroup">
<button id="btnRoutstrRefund" class="btn" style="width: 100%;">Withdraw Change (Refund)</button>
<div class="aiConfigField">
<label for="taRoutstrRefundToken">Refund Token</label>
<textarea id="taRoutstrRefundToken" class="aiTextarea" readonly placeholder="cashuA... refund token appears here"></textarea>
</div>
</div>
<div id="divRoutstrOpsStatus" class="aiConversationPreview" style="white-space: pre-wrap;">Ready.</div>
</div>
</div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</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>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey, injectHeaderAvatar,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings,
subscribe,
publishEvent,
fetchCachedProfile
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import {
initFooterRelayStatus,
updateFooterRelayStatus,
initSidenavRelaySection,
updateSidenavRelaySection,
setRelayActivityState
} from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { uploadToServer, getDefaultBlossomServer } from './js/blossom-api.mjs';
const AI_CONFIG_KEY = 'ai_chat_openai_config_v1';
let updateIntervalId = null;
let currentPubkey = null;
let hamburgerInstance = null;
let isNavOpen = false;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
const DEFAULT_TV_SYSTEM_PROMPT = 'You are a web page generator. You must respond to ALL requests with a complete, valid HTML page. Your response must be ONLY the HTML — no markdown, no explanation, no code fences. The page should be self-contained with inline CSS and JavaScript as needed.';
const DEFAULT_SKILL_TEMPLATE = `system:
${DEFAULT_TV_SYSTEM_PROMPT}
user:
{{message}}`;
let isSending = false;
let skills = [];
let skillFilterMode = 'all';
let selectedSkillKey = null;
let skillSubscription = null;
let skillSubId = null;
let renderedPages = [];
let selectedRenderedPageId = null;
const authorProfiles = new Map();
const profileLookupInFlight = new Set();
let aiConfig = {
provider: 'ppq.ai',
api_key: '',
credit_id: '',
model: 'claude-haiku-4.5',
base_url: 'https://api.ppq.ai',
max_tokens: 512,
temperature: 0.7,
providers: [
{ name: 'ppq.ai', url: 'https://api.ppq.ai' },
{ name: 'Routstr', url: 'https://api.routstr.com' }
],
favorites: []
};
let fetchedModels = [];
let fetchModels = async () => {};
let activeInvoicePollId = 0;
let previewPopoutWindow = null;
const divSideNav = document.getElementById('divSideNav');
const divSideNavBody = document.getElementById('divSideNavBody');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const divFooterBalance = document.getElementById('divFooterBalance');
const divAiConversationsList = document.getElementById('divAiConversationsList');
const divAiMessages = document.getElementById('divAiMessages');
const divAiStatus = document.getElementById('divAiStatus');
const divAiProviderBalanceHeader = document.getElementById('divAiProviderBalanceHeader');
const iframeAiTv = document.getElementById('iframeAiTv');
const divAiViewerLoading = document.getElementById('divAiViewerLoading');
const selAiProvider = document.getElementById('selAiProvider');
const selAiModel = document.getElementById('selAiModel');
const taAiPrompt = document.getElementById('taAiPrompt');
const taAiSystemPrompt = document.getElementById('taAiSystemPrompt');
const divAiInputArea = document.getElementById('divAiInputArea');
const btnAiRun = document.getElementById('btnAiRun');
const btnAiSaveSkill = document.getElementById('btnAiSaveSkill');
const btnSkillFilterAll = document.getElementById('btnSkillFilterAll');
const btnSkillFilterMy = document.getElementById('btnSkillFilterMy');
const btnSkillNew = document.getElementById('btnSkillNew');
const btnSkillDelete = document.getElementById('btnSkillDelete');
const btnSkillRefresh = document.getElementById('btnSkillRefresh');
const inpSkillSlug = document.getElementById('inpSkillSlug');
const inpSkillDescription = document.getElementById('inpSkillDescription');
const taSkillTemplate = document.getElementById('taSkillTemplate');
const inpSkillTemperature = document.getElementById('inpSkillTemperature');
const inpSkillMaxTokens = document.getElementById('inpSkillMaxTokens');
const inpSkillSeed = document.getElementById('inpSkillSeed');
const btnAiSaveRenderedPage = document.getElementById('btnAiSaveRenderedPage');
const btnAiPopoutPreview = document.getElementById('btnAiPopoutPreviewOverlay');
const lnkAiLaunchRenderedPage = document.getElementById('lnkAiLaunchRenderedPage');
const selCfgProvider = document.getElementById('selCfgProvider');
const inpCfgBaseUrl = document.getElementById('inpCfgBaseUrl');
const inpCfgApiKey = document.getElementById('inpCfgApiKey');
const inpCfgCreditId = document.getElementById('inpCfgCreditId');
const divPpqCreditIdGroup = document.getElementById('divPpqCreditIdGroup');
const selCfgModel = document.getElementById('selCfgModel');
const inpCfgModelFilter = document.getElementById('inpCfgModelFilter');
const btnCfgFav = document.getElementById('btnCfgFav');
const ddAiModel = document.getElementById('ddAiModel');
const btnAiModelDropdown = document.getElementById('btnAiModelDropdown');
const inpAiModelFilter = document.getElementById('inpAiModelFilter');
const divAiModelOptions = document.getElementById('divAiModelOptions');
const ddCfgModel = document.getElementById('ddCfgModel');
const btnCfgModelDropdown = document.getElementById('btnCfgModelDropdown');
const divCfgModelOptions = document.getElementById('divCfgModelOptions');
const inpCfgMaxTokens = document.getElementById('inpCfgMaxTokens');
const inpCfgTemperature = document.getElementById('inpCfgTemperature');
const btnCfgSave = document.getElementById('btnCfgSave');
const btnCfgCopyApiKey = document.getElementById('btnCfgCopyApiKey');
const btnRoutstrGetBalance = document.getElementById('btnRoutstrGetBalance');
const divRoutstrBalanceState = document.getElementById('divRoutstrBalanceState');
const inpRoutstrDepositSats = document.getElementById('inpRoutstrDepositSats');
const btnRoutstrCreateInvoice = document.getElementById('btnRoutstrCreateInvoice');
const inpRoutstrDepositCashu = document.getElementById('inpRoutstrDepositCashu');
const btnRoutstrImportCashu = document.getElementById('btnRoutstrImportCashu');
const inpRoutstrTopupSats = document.getElementById('inpRoutstrTopupSats');
const btnRoutstrTopupInvoice = document.getElementById('btnRoutstrTopupInvoice');
const inpRoutstrTopupCashu = document.getElementById('inpRoutstrTopupCashu');
const btnRoutstrTopupCashu = document.getElementById('btnRoutstrTopupCashu');
const btnRoutstrRefund = document.getElementById('btnRoutstrRefund');
const taRoutstrRefundToken = document.getElementById('taRoutstrRefundToken');
const divRoutstrDepositInvoiceMeta = document.getElementById('divRoutstrDepositInvoiceMeta');
const divRoutstrDepositInvoiceStatus = document.getElementById('divRoutstrDepositInvoiceStatus');
const divRoutstrDepositInvoiceQr = document.getElementById('divRoutstrDepositInvoiceQr');
const taRoutstrDepositInvoiceBolt11 = document.getElementById('taRoutstrDepositInvoiceBolt11');
const divRoutstrTopupInvoiceMeta = document.getElementById('divRoutstrTopupInvoiceMeta');
const divRoutstrTopupInvoiceStatus = document.getElementById('divRoutstrTopupInvoiceStatus');
const divRoutstrTopupInvoiceQr = document.getElementById('divRoutstrTopupInvoiceQr');
const taRoutstrTopupInvoiceBolt11 = document.getElementById('taRoutstrTopupInvoiceBolt11');
const divRoutstrOpsStatus = document.getElementById('divRoutstrOpsStatus');
const divRoutstrOnlySections = document.getElementById('divRoutstrOnlySections');
const markedLib = window.marked;
if (markedLib && markedLib.setOptions) {
markedLib.setOptions({
breaks: true,
gfm: true
});
}
function uid() {
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function nowTs() {
return Date.now();
}
function escapeHtml(value) {
const span = document.createElement('span');
span.textContent = String(value || '');
return span.innerHTML;
}
function extractHtmlFromResponse(rawContent) {
let html = String(rawContent || '').trim();
const fencePattern = /^```(?:html)?\s*\n?([\s\S]*?)\n?\s*```$/i;
const match = html.match(fencePattern);
if (match && match[1]) html = match[1].trim();
const lower = html.toLowerCase();
if (!lower.includes('<html') && !lower.includes('<!doctype')) {
const escaped = escapeHtml(html).replaceAll('\n', '<br/>');
html = `<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>HTML TV Page</title></head><body style="font-family: Arial, sans-serif; padding: 16px;">${escaped}</body></html>`;
}
return html;
}
function extractPageTitleFromHtml(html, fallback = 'Untitled page') {
const raw = String(html || '');
const titleMatch = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleMatch && titleMatch[1]) {
const text = String(titleMatch[1]).replace(/\s+/g, ' ').trim();
if (text) return text.slice(0, 72);
}
return fallback;
}
function setStatus(text) {
divAiStatus.textContent = String(text || '');
}
function setViewerLoading(isActive) {
if (!divAiViewerLoading) return;
divAiViewerLoading.classList.toggle('active', Boolean(isActive));
}
function syncPopoutPreview(html) {
if (!previewPopoutWindow || previewPopoutWindow.closed) return;
try {
previewPopoutWindow.document.open();
previewPopoutWindow.document.write(String(html || ''));
previewPopoutWindow.document.close();
} catch (error) {
console.warn('[html-tv.html] failed to sync popout preview:', error);
}
}
function popoutPreviewWindow() {
const html = String(iframeAiTv?.srcdoc || '').trim();
if (!html) {
setStatus('No preview content available yet. Run a skill first.');
return;
}
const features = 'popup=yes,width=1200,height=900,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes';
const popup = window.open('', 'skillsTvPreview', features);
if (!popup) {
setStatus('Popup blocked by browser. Allow popups for this site and try again.');
return;
}
previewPopoutWindow = popup;
syncPopoutPreview(html);
popup.focus();
setStatus('Preview popped out to a separate window.');
}
function renderViewerHtml(html) {
if (!iframeAiTv) return;
const nextHtml = String(html || '');
iframeAiTv.srcdoc = nextHtml;
syncPopoutPreview(nextHtml);
}
function getEmptyViewerHtml(text = 'Run & Save Skill to generate a page...') {
return `<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>HTML TV</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Arial,sans-serif;color:#777;background:#fff;padding:24px;text-align:center;}</style></head><body>${escapeHtml(text)}</body></html>`;
}
function skillKey(pubkey, slug) {
return `${String(pubkey || '').trim()}:${String(slug || '').trim()}`;
}
function normalizeSlug(raw) {
return String(raw || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-_]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function getTagValue(tags, name) {
const tag = (Array.isArray(tags) ? tags : []).find((t) => Array.isArray(t) && t[0] === name);
return tag && tag[1] ? String(tag[1]) : '';
}
function hasTagValue(tags, name, value) {
return (Array.isArray(tags) ? tags : []).some((t) => Array.isArray(t) && t[0] === name && t[1] === value);
}
function shortPubkey(pubkey) {
const value = String(pubkey || '');
if (value.length <= 14) return value;
return `${value.slice(0, 6)}...${value.slice(-5)}`;
}
function getAuthorProfile(pubkey) {
const profile = authorProfiles.get(String(pubkey || '')) || null;
if (!profile) return { name: shortPubkey(pubkey), picture: '' };
const preferredName = String(profile.display_name || profile.name || '').trim();
return {
name: preferredName || shortPubkey(pubkey),
picture: String(profile.picture || '').trim()
};
}
async function lookupAuthorProfile(pubkey) {
const author = String(pubkey || '').trim();
if (!author || authorProfiles.has(author) || profileLookupInFlight.has(author)) return;
profileLookupInFlight.add(author);
try {
const profile = await fetchCachedProfile(author);
if (profile && typeof profile === 'object') {
authorProfiles.set(author, profile);
renderSkillsList();
}
} catch (_error) {
// ignore
} finally {
profileLookupInFlight.delete(author);
}
}
function parseSkillEvent(evt) {
if (!evt || evt.kind !== 31123) return null;
if (!hasTagValue(evt.tags, 'm', 'text/html')) return null;
const slug = normalizeSlug(getTagValue(evt.tags, 'd'));
if (!slug) return null;
const skill = {
key: skillKey(evt.pubkey, slug),
slug,
description: getTagValue(evt.tags, 'description') || slug,
template: '',
temperature: 0,
max_tokens: 4000,
llm: 'default',
seed: 0,
author: String(evt.pubkey || ''),
created_at: Number(evt.created_at || 0),
valid: true,
error: '',
raw: evt
};
try {
const rawContent = String(evt.content || '').trim();
const parsed = rawContent ? JSON.parse(rawContent) : {};
const content = parsed && typeof parsed === 'object' ? parsed : {};
console.log('[html-tv.html] parseSkillEvent:content-json', {
slug,
rawContent,
parsedContent: content
});
skill.description = String(skill.description || content?.description || slug).trim();
skill.template = String(content?.template || '').trim();
skill.temperature = Number.isFinite(Number(content?.temperature)) ? Number(content.temperature) : 0;
skill.max_tokens = Number.isFinite(Number(content?.max_tokens)) ? Math.max(100, Math.floor(Number(content.max_tokens))) : 4000;
skill.llm = String(content?.llm || 'default').trim() || 'default';
skill.seed = Number.isFinite(Number(content?.seed)) ? Math.floor(Number(content.seed)) : 0;
if (!skill.template) {
const fallbackTemplate = rawContent;
if (fallbackTemplate.includes('system:') || fallbackTemplate.includes('{{message}}')) {
skill.template = fallbackTemplate;
}
}
if (!Number.isFinite(Number(content?.temperature))) {
const tempTag = Number(getTagValue(evt.tags, 'temperature'));
if (Number.isFinite(tempTag)) skill.temperature = tempTag;
}
if (!Number.isFinite(Number(content?.max_tokens))) {
const tokensTag = Number(getTagValue(evt.tags, 'max_tokens'));
if (Number.isFinite(tokensTag)) skill.max_tokens = Math.max(100, Math.floor(tokensTag));
}
if (!String(content?.llm || '').trim()) {
const llmTag = String(getTagValue(evt.tags, 'llm') || '').trim();
if (llmTag) skill.llm = llmTag;
}
if (!Number.isFinite(Number(content?.seed))) {
const seedTag = Number(getTagValue(evt.tags, 'seed'));
if (Number.isFinite(seedTag)) skill.seed = Math.floor(seedTag);
}
if (!skill.template) {
skill.valid = false;
skill.error = 'Invalid skill format';
}
} catch (_error) {
const rawContent = String(evt.content || '').trim();
if (rawContent.includes('system:') || rawContent.includes('{{message}}')) {
skill.template = rawContent;
skill.valid = true;
skill.error = '';
} else {
skill.valid = false;
skill.error = 'Invalid skill format';
}
}
return skill;
}
function splitSkillTemplate(template) {
const raw = String(template || '').trim();
const marker = '\nuser:\n';
const idx = raw.indexOf(marker);
if (idx === -1) {
return {
system: raw.replace(/^system:\n/, '').trim(),
userTemplate: ''
};
}
return {
system: raw.substring(0, idx).replace(/^system:\n/, '').trim(),
userTemplate: raw.substring(idx + marker.length).trim()
};
}
function parseSkillTemplate(template, userInput) {
const parts = splitSkillTemplate(template);
const system = String(parts.system || '').trim();
const userTemplate = String(parts.userTemplate || '').trim();
const user = userTemplate
? userTemplate.replaceAll('{{message}}', String(userInput || '').trim()).trim()
: String(userInput || '').trim();
return { system, user };
}
function buildStoredTemplateWithUserInput(template, userInput) {
const parts = splitSkillTemplate(template);
const system = String(parts.system || '').trim();
const userTemplate = String(parts.userTemplate || '{{message}}').trim();
const resolvedUser = userTemplate.replaceAll('{{message}}', String(userInput || '').trim()).trim();
return `system:\n${system}\n\nuser:\n${resolvedUser}`.trim();
}
function hydrateEditorFromStoredTemplate(storedTemplate) {
const raw = String(storedTemplate || '').trim();
const marker = '\nuser:\n';
const idx = raw.indexOf(marker);
if (idx === -1) {
return {
template: raw || DEFAULT_SKILL_TEMPLATE,
message: ''
};
}
const system = raw.substring(0, idx).replace(/^system:\n/, '').trim();
const message = raw.substring(idx + marker.length).trim();
return {
template: `system:\n${system}\n\nuser:\n{{message}}`,
message
};
}
function buildSkillEvent(slug, description, template, temperature, maxTokens, llm = 'default', seed = null) {
return {
kind: 31123,
content: JSON.stringify({
description,
context_mode: 'full',
llm: String(llm || 'default').trim() || 'default',
tools: false,
max_tokens: maxTokens,
temperature,
seed: Number.isFinite(Number(seed)) ? Math.floor(Number(seed)) : null,
template
}),
tags: [
['d', slug],
['m', 'text/html'],
['scope', 'public'],
['description', description]
],
created_at: Math.floor(Date.now() / 1000)
};
}
function getSelectedSkill() {
return skills.find((s) => s.key === selectedSkillKey) || null;
}
function applySkillFilterUi() {
if (btnSkillFilterAll) btnSkillFilterAll.classList.toggle('active', skillFilterMode === 'all');
if (btnSkillFilterMy) btnSkillFilterMy.classList.toggle('active', skillFilterMode === 'my');
}
function renderRenderedPageActions() {
const page = renderedPages.find((p) => p.id === selectedRenderedPageId) || null;
if (btnAiSaveRenderedPage) btnAiSaveRenderedPage.disabled = !page;
if (!lnkAiLaunchRenderedPage) return;
if (page && page.blossomUrl) {
lnkAiLaunchRenderedPage.href = page.blossomUrl;
lnkAiLaunchRenderedPage.style.display = '';
} else {
lnkAiLaunchRenderedPage.href = '#';
lnkAiLaunchRenderedPage.style.display = 'none';
}
}
function renderSkillsList() {
const scoped = skills
.filter((skill) => (skillFilterMode === 'my' ? Boolean(currentPubkey && skill.author === currentPubkey) : true))
.sort((a, b) => a.slug.localeCompare(b.slug));
if (scoped.length === 0) {
divAiConversationsList.innerHTML = '<div class="aiConversationPreview">No skills found.</div>';
if (btnSkillDelete) btnSkillDelete.disabled = true;
applySkillFilterUi();
return;
}
const html = scoped.map((skill) => {
const author = getAuthorProfile(skill.author);
const avatar = author.picture
? `<img class="avatar avatar--md avatar--square divPostAvatar skillAuthorAvatar" src="${escapeHtml(author.picture)}" alt="${escapeHtml(author.name)}" loading="lazy" />`
: '<div class="avatar avatar--md avatar--square" style="border:1px solid var(--muted-color);"></div>';
const active = skill.key === selectedSkillKey ? 'active' : '';
return `
<div class="aiConversationItem ${active}" data-skill-key="${escapeHtml(skill.key)}">
<div class="aiConversationHeader">
<div class="aiConversationTitle">${escapeHtml(skill.slug)}</div>
${avatar}
</div>
<div class="aiConversationPreview">${escapeHtml(skill.description || '')}</div>
<div class="aiConversationPreview">${escapeHtml(author.name)}</div>
</div>
`;
}).join('');
divAiConversationsList.innerHTML = html;
Array.from(divAiConversationsList.querySelectorAll('.aiConversationItem[data-skill-key]')).forEach((el) => {
el.addEventListener('click', () => {
const key = String(el.getAttribute('data-skill-key') || '');
selectedSkillKey = key;
const skill = getSelectedSkill();
if (skill) {
const hydrated = hydrateEditorFromStoredTemplate(skill.template || DEFAULT_SKILL_TEMPLATE);
inpSkillSlug.value = skill.slug;
inpSkillDescription.value = skill.description;
taSkillTemplate.value = hydrated.template || DEFAULT_SKILL_TEMPLATE;
taAiPrompt.value = hydrated.message || '';
inpSkillTemperature.value = String(skill.temperature ?? 0);
inpSkillMaxTokens.value = String(skill.max_tokens ?? 4000);
if (inpSkillSeed) inpSkillSeed.value = Number.isFinite(Number(skill.seed)) ? String(Math.floor(Number(skill.seed))) : '0';
if (skill.llm && skill.llm !== 'default') selectModel(skill.llm);
const rawContent = String(skill?.raw?.content || '').trim();
let parsedContent = null;
try {
parsedContent = rawContent ? JSON.parse(rawContent) : null;
} catch (_error) {
parsedContent = null;
}
console.log('[html-tv.html] skill:selected', {
slug: skill.slug,
hasTemplate: Boolean(skill.template),
temperature: skill.temperature,
max_tokens: skill.max_tokens,
seed: skill.seed,
llm: skill.llm,
rawContent,
parsedContent,
hydrated,
selectedFormState: {
slug: String(inpSkillSlug?.value || ''),
description: String(inpSkillDescription?.value || ''),
template: String(taSkillTemplate?.value || ''),
message: String(taAiPrompt?.value || ''),
temperature: String(inpSkillTemperature?.value || ''),
max_tokens: String(inpSkillMaxTokens?.value || ''),
seed: String(inpSkillSeed?.value || ''),
llm: String(selAiModel?.value || '')
}
});
}
renderSkillsList();
});
});
applySkillFilterUi();
const selected = getSelectedSkill();
if (btnSkillDelete) btnSkillDelete.disabled = !selected || !currentPubkey || selected.author !== currentPubkey;
for (const skill of scoped) {
lookupAuthorProfile(skill.author);
}
}
function upsertSkillFromEvent(evt) {
const parsed = parseSkillEvent(evt);
if (!parsed) return;
const idx = skills.findIndex((s) => s.key === parsed.key);
if (idx >= 0) {
const prevTs = Number(skills[idx]?.created_at || 0);
const nextTs = Number(parsed.created_at || 0);
if (nextTs >= prevTs) skills[idx] = parsed;
} else {
skills.push(parsed);
}
if (!selectedSkillKey) selectedSkillKey = parsed.key;
if (!skills.some((x) => x.key === selectedSkillKey)) selectedSkillKey = skills[0]?.key || null;
renderSkillsList();
}
function clearSkillSubscription() {
if (skillSubscription && typeof skillSubscription.close === 'function') skillSubscription.close();
skillSubscription = null;
skillSubId = null;
}
function refreshSkills() {
clearSkillSubscription();
skills = [];
selectedSkillKey = null;
renderSkillsList();
setStatus('Loading skills...');
const filter = {
kinds: [31123],
'#m': ['text/html'],
limit: 200
};
if (skillFilterMode === 'my' && currentPubkey) {
filter.authors = [currentPubkey];
}
skillSubscription = subscribe(filter, {
closeOnEose: false,
cacheUsage: 'CACHE_FIRST'
});
skillSubId = skillSubscription?.subId || null;
}
function renderCurrentConversation() {
renderRenderedPageActions();
}
function renderConversationList() {
renderSkillsList();
}
function loadConversations() {
renderedPages = [];
selectedRenderedPageId = null;
if (inpSkillSlug) inpSkillSlug.value = 'html-page-generator';
if (inpSkillDescription) inpSkillDescription.value = 'Generate a complete HTML page from user input';
if (taSkillTemplate) taSkillTemplate.value = DEFAULT_SKILL_TEMPLATE;
if (inpSkillTemperature) inpSkillTemperature.value = '0';
if (inpSkillMaxTokens) inpSkillMaxTokens.value = '4000';
if (inpSkillSeed) inpSkillSeed.value = '0';
if (taAiSystemPrompt) taAiSystemPrompt.value = DEFAULT_TV_SYSTEM_PROMPT;
renderViewerHtml(getEmptyViewerHtml('Select a skill or create one, then run it.'));
renderRenderedPageActions();
refreshSkills();
}
function createNewSkill() {
if (inpSkillSlug) inpSkillSlug.value = '';
if (inpSkillDescription) inpSkillDescription.value = '';
if (taSkillTemplate) taSkillTemplate.value = DEFAULT_SKILL_TEMPLATE;
if (taAiPrompt) taAiPrompt.value = '';
if (inpSkillTemperature) inpSkillTemperature.value = '0';
if (inpSkillMaxTokens) inpSkillMaxTokens.value = '4000';
if (inpSkillSeed) inpSkillSeed.value = '0';
selectedSkillKey = null;
renderViewerHtml(getEmptyViewerHtml('New skill template ready. Enter a prompt and run.'));
renderRenderedPageActions();
renderSkillsList();
if (inpSkillSlug) inpSkillSlug.focus();
setStatus('New skill template ready.');
}
function updateConversation() {}
function addMessage(role, content, options = {}) {
return {
id: uid(), role, content: String(content || ''), ...options
};
}
async function saveAssistantPageToBlossom(pageId) {
const page = renderedPages.find((p) => p.id === pageId);
if (!page) return;
const defaultServer = getDefaultBlossomServer();
if (!defaultServer) {
setStatus('No Blossom server configured. Add one in the sidebar first.');
return;
}
const pageHtml = String(page.html || '');
const safeTitle = extractPageTitleFromHtml(pageHtml, 'html-tv-page')
.toLowerCase()
.replace(/[^a-z0-9-_]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'html-tv-page';
const file = new File([pageHtml], `${safeTitle}.html`, { type: 'text/html;charset=utf-8' });
try {
setStatus(`Saving page to Blossom (${defaultServer})...`);
const result = await uploadToServer(file, defaultServer);
const base = String(result?.serverUrl || defaultServer).replace(/\/+$/, '');
const sha = String(result?.sha256 || '').trim();
if (!sha) throw new Error('Upload completed but no sha256 returned.');
const blossomUrl = `${base}/${sha}`;
page.blossomUrl = blossomUrl;
renderRenderedPageActions();
setStatus(`Saved to Blossom: ${blossomUrl}`);
} catch (error) {
setStatus(`Blossom save failed: ${String(error?.message || error)}`);
}
}
async function deleteSelectedSkill() {
const skill = getSelectedSkill();
if (!skill) return setStatus('Select a skill first.');
if (!currentPubkey || skill.author !== currentPubkey) return setStatus('You can only delete your own skills.');
if (!confirm(`Delete skill "${skill.slug}"?`)) return;
const delEvent = {
kind: 5,
content: '',
tags: [['a', `31123:${currentPubkey}:${skill.slug}`]],
created_at: Math.floor(Date.now() / 1000)
};
if (skill?.raw?.id) delEvent.tags.push(['e', skill.raw.id]);
try {
await publishEvent(delEvent);
skills = skills.filter((x) => x.key !== skill.key);
selectedSkillKey = skills[0]?.key || null;
renderSkillsList();
setStatus(`Deleted skill: ${skill.slug}`);
} catch (error) {
setStatus(`Delete failed: ${String(error?.message || error)}`);
}
}
function normalizeBaseUrl(baseUrlRaw) {
const raw = String(baseUrlRaw || '').trim().replace(/\/+$/, '');
if (!raw) return '';
return raw;
}
function getChatCompletionsUrl(baseUrlRaw) {
const base = normalizeBaseUrl(baseUrlRaw);
if (!base) return '';
if (base.endsWith('/v1')) return `${base}/chat/completions`;
return `${base}/v1/chat/completions`;
}
function loadAiConfig() {
try {
const raw = localStorage.getItem(AI_CONFIG_KEY);
const parsed = raw ? JSON.parse(raw) : null;
if (parsed && typeof parsed === 'object') {
aiConfig = {
...aiConfig,
...parsed,
max_tokens: Number(parsed.max_tokens ?? aiConfig.max_tokens),
temperature: Number(parsed.temperature ?? aiConfig.temperature)
};
}
} catch (_error) {
// keep defaults
}
}
function normalizeAiConfig(input = {}) {
return {
provider: String(input?.provider || 'ppq.ai').trim(),
api_key: String(input?.api_key || '').trim(),
credit_id: String(input?.credit_id || input?.creditId || '').trim(),
model: String(input?.model || '').trim(),
base_url: String(input?.base_url || '').trim(),
max_tokens: Math.max(1, Math.floor(Number(input?.max_tokens ?? 512))),
temperature: Number(input?.temperature ?? 0.7),
providers: Array.isArray(input?.providers) ? input.providers : aiConfig.providers,
favorites: Array.isArray(input?.favorites) ? input.favorites : []
};
}
function mergeProvidersPreservingSecrets(existingProviders = [], incomingProviders = []) {
const existingByName = new Map(
(Array.isArray(existingProviders) ? existingProviders : [])
.map((p) => [String(p?.name || '').trim(), p])
.filter(([name]) => Boolean(name))
);
return (Array.isArray(incomingProviders) ? incomingProviders : []).map((p) => {
const name = String(p?.name || '').trim();
const existing = existingByName.get(name) || {};
return {
...p,
api_key: String(p?.api_key || p?.apiKey || existing?.api_key || existing?.apiKey || '').trim(),
credit_id: String(p?.credit_id || p?.creditId || existing?.credit_id || existing?.creditId || '').trim(),
model: String(p?.model || existing?.model || '').trim(),
max_tokens: Number(p?.max_tokens ?? p?.maxTokens ?? existing?.max_tokens ?? existing?.maxTokens ?? 512),
temperature: Number(p?.temperature ?? existing?.temperature ?? 0.7)
};
});
}
function mergeAiConfigFromSettings(currentConfig, settingsAiRaw = {}) {
const incoming = normalizeAiConfig(settingsAiRaw);
return {
...currentConfig,
...incoming,
api_key: incoming.api_key || String(currentConfig?.api_key || '').trim(),
credit_id: incoming.credit_id || String(currentConfig?.credit_id || '').trim(),
providers: mergeProvidersPreservingSecrets(currentConfig?.providers || [], incoming.providers || [])
};
}
function saveAiConfigLocal() {
localStorage.setItem(AI_CONFIG_KEY, JSON.stringify(aiConfig));
}
async function saveAiConfigToUserSettings() {
const normalized = normalizeAiConfig(aiConfig);
aiConfig = {
...aiConfig,
...normalized
};
await patchUserSettings({ global_llm: normalized });
}
function getProviderEntries() {
return (aiConfig.providers || [])
.map((p) => ({
name: String(p?.name || '').trim(),
url: String(p?.url || '').trim(),
api_key: String(p?.api_key || p?.apiKey || '').trim(),
credit_id: String(p?.credit_id || p?.creditId || '').trim(),
model: String(p?.model || '').trim(),
max_tokens: Number(p?.max_tokens ?? p?.maxTokens ?? NaN),
temperature: Number(p?.temperature ?? NaN)
}))
.filter((p) => p.name && p.url);
}
function setDropdownOpen(dropdownEl, open) {
if (!dropdownEl) return;
dropdownEl.classList.toggle('open', Boolean(open));
}
function closeAllCustomDropdowns() {
setDropdownOpen(ddAiModel, false);
setDropdownOpen(ddCfgModel, false);
}
function syncSelectedProviderProfile() {
const selectedName = String(aiConfig.provider || '').trim();
if (!selectedName || selectedName === 'custom') return;
const currentProviders = Array.isArray(aiConfig.providers) ? [...aiConfig.providers] : [];
const existingIndex = currentProviders.findIndex((p) => String(p?.name || '').trim() === selectedName);
const nextEntry = {
...(existingIndex >= 0 ? currentProviders[existingIndex] : {}),
name: selectedName,
url: String(aiConfig.base_url || '').trim(),
api_key: String(aiConfig.api_key || '').trim(),
credit_id: String(aiConfig.credit_id || '').trim(),
model: String(aiConfig.model || '').trim(),
max_tokens: Math.max(1, Math.floor(Number(aiConfig.max_tokens || 512))),
temperature: Number(aiConfig.temperature ?? 0.7)
};
if (existingIndex >= 0) {
currentProviders[existingIndex] = nextEntry;
} else {
currentProviders.push(nextEntry);
}
aiConfig.providers = currentProviders;
}
function selectProvider(providerName) {
const selectedName = String(providerName || '').trim();
if (!selectedName || selectedName === 'custom') return;
const next = getProviderEntries().find((p) => p.name === selectedName);
if (!next) return;
aiConfig.provider = next.name;
aiConfig.base_url = next.url;
aiConfig.api_key = String(next.api_key || '').trim();
aiConfig.credit_id = String(next.credit_id || '').trim();
aiConfig.model = String(next.model || '').trim();
aiConfig.max_tokens = Number.isFinite(next.max_tokens) && next.max_tokens > 0 ? Math.floor(next.max_tokens) : 512;
aiConfig.temperature = Number.isFinite(next.temperature) ? next.temperature : 0.7;
inpCfgBaseUrl.value = aiConfig.base_url;
inpCfgApiKey.value = aiConfig.api_key;
if (inpCfgCreditId) inpCfgCreditId.value = String(aiConfig.credit_id || '');
inpCfgMaxTokens.value = String(aiConfig.max_tokens);
inpCfgTemperature.value = String(aiConfig.temperature);
selCfgProvider.value = next.name;
selAiProvider.value = next.name;
saveAiConfigLocal();
saveAiConfigToUserSettings().catch((error) => {
console.warn('[html-tv.html] failed to persist provider to user-settings:', error);
});
fetchModels();
applyAiConfigToHeader();
updateProviderSections();
getRoutstrBalance().catch((error) => {
console.warn('[html-tv.html] provider change balance refresh failed:', error);
});
}
function renderProviderDropdown() {
const providers = getProviderEntries();
selCfgProvider.innerHTML =
providers.map(p => `<option value="${escapeHtml(p.name)}" ${p.name === aiConfig.provider ? 'selected' : ''}>${escapeHtml(p.name)}</option>`).join('') +
'<option value="custom">Custom...</option>';
selAiProvider.innerHTML = providers
.map(p => `<option value="${escapeHtml(p.name)}" ${p.name === aiConfig.provider ? 'selected' : ''}>${escapeHtml(p.name)}</option>`)
.join('');
if (aiConfig.provider) selAiProvider.value = aiConfig.provider;
}
function getOrderedModels(filterText = '') {
const favorites = aiConfig.favorites || [];
const terms = String(filterText || '')
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
const base = (fetchedModels || [])
.map((m) => ({ id: String(m?.id || '').trim() }))
.filter((m) => m.id);
let filtered = base;
if (terms.length > 0) {
filtered = filtered.filter((m) => {
const id = m.id.toLowerCase();
return terms.every((term) => id.includes(term));
});
}
filtered.sort((a, b) => a.id.localeCompare(b.id));
const fav = filtered.filter((m) => favorites.includes(m.id));
const other = filtered.filter((m) => !favorites.includes(m.id));
return { fav, other };
}
function renderModelList(containerEl, filterValue = '') {
if (!containerEl) return;
const { fav, other } = getOrderedModels(filterValue);
const currentModel = String(aiConfig.model || '').trim();
const rows = [];
for (const m of fav) {
rows.push(`<div class="aiDropdownOption favorite ${m.id === currentModel ? 'active' : ''}" data-model-id="${escapeHtml(m.id)}">${escapeHtml(m.id)}</div>`);
}
for (const m of other) {
rows.push(`<div class="aiDropdownOption ${m.id === currentModel ? 'active' : ''}" data-model-id="${escapeHtml(m.id)}">${escapeHtml(m.id)}</div>`);
}
containerEl.innerHTML = rows.join('') || '<div class="aiDropdownHint">No models match filter.</div>';
}
function selectModel(nextModel) {
const model = String(nextModel || '').trim();
if (!model) return;
aiConfig.model = model;
selAiModel.value = model;
selCfgModel.value = model;
syncSelectedProviderProfile();
saveAiConfigLocal();
saveAiConfigToUserSettings().catch((error) => {
console.warn('[html-tv.html] failed to persist model to user-settings:', error);
});
applyAiConfigToHeader();
}
function updateFavButton() {
const current = String(selCfgModel?.value || '').trim();
const isFav = (aiConfig.favorites || []).includes(current);
if (btnCfgFav) {
btnCfgFav.textContent = isFav ? '★' : '☆';
btnCfgFav.style.color = isFav ? 'var(--accent-color)' : 'var(--muted-color)';
}
}
function bindModelOptionClicks() {
const bind = (container) => {
if (!container) return;
Array.from(container.querySelectorAll('.aiDropdownOption[data-model-id]')).forEach((el) => {
el.addEventListener('click', () => {
const next = String(el.getAttribute('data-model-id') || '').trim();
selectModel(next);
closeAllCustomDropdowns();
});
});
};
bind(divAiModelOptions);
bind(divCfgModelOptions);
}
function renderModelDropdown() {
const { fav, other } = getOrderedModels('');
const currentModel = String(aiConfig.model || '').trim();
let html = '';
if (fav.length > 0) {
html += '<optgroup label="Favorites">';
html += fav.map(m => `<option value="${escapeHtml(m.id)}" ${m.id === currentModel ? 'selected' : ''}>★ ${escapeHtml(m.id)}</option>`).join('');
html += '</optgroup>';
}
if (other.length > 0) {
html += '<optgroup label="All Models">';
html += other.map(m => `<option value="${escapeHtml(m.id)}" ${m.id === currentModel ? 'selected' : ''}>${escapeHtml(m.id)}</option>`).join('');
html += '</optgroup>';
}
if (!html && currentModel) {
html = `<option value="${escapeHtml(currentModel)}" selected>${escapeHtml(currentModel)}</option>`;
}
selCfgModel.innerHTML = html;
selAiModel.innerHTML = html;
if (currentModel) {
selCfgModel.value = currentModel;
selAiModel.value = currentModel;
}
renderModelList(divAiModelOptions, inpAiModelFilter?.value || '');
renderModelList(divCfgModelOptions, inpCfgModelFilter?.value || '');
bindModelOptionClicks();
if (btnAiModelDropdown) btnAiModelDropdown.textContent = currentModel || 'Model';
if (btnCfgModelDropdown) btnCfgModelDropdown.textContent = currentModel || 'Model';
updateFavButton();
}
function renderAiConfigForm() {
renderProviderDropdown();
inpCfgBaseUrl.value = String(aiConfig.base_url || '');
inpCfgApiKey.value = String(aiConfig.api_key || '');
if (inpCfgCreditId) inpCfgCreditId.value = String(aiConfig.credit_id || '');
updateProviderSections();
// model dropdown handled by fetchModels
inpCfgMaxTokens.value = String(aiConfig.max_tokens || 512);
inpCfgTemperature.value = String(aiConfig.temperature ?? 0.7);
}
function applyAiConfigToHeader() {
renderProviderDropdown();
renderModelDropdown();
}
function getRoutstrBaseUrl() {
return normalizeBaseUrl(inpCfgBaseUrl?.value || aiConfig.base_url || '');
}
function getAuthToken() {
return String(inpCfgApiKey?.value || aiConfig.api_key || '').trim();
}
function updateProviderSections() {
const provider = String(selCfgProvider?.value || aiConfig.provider || '').trim().toLowerCase();
const isPpq = provider === 'ppq.ai';
const isRoutstr = provider === 'routstr';
if (divPpqCreditIdGroup) divPpqCreditIdGroup.style.display = isPpq ? '' : 'none';
if (divRoutstrOnlySections) divRoutstrOnlySections.style.display = isRoutstr ? '' : 'none';
}
function setRoutstrOpsStatus(text) {
if (divRoutstrOpsStatus) divRoutstrOpsStatus.textContent = String(text || '');
}
function setRoutstrBalanceState(text) {
if (divRoutstrBalanceState) divRoutstrBalanceState.textContent = String(text || '');
}
function setHeaderProviderBalance(text) {
if (divAiProviderBalanceHeader) divAiProviderBalanceHeader.textContent = String(text || '--');
}
function getInvoiceElements(kind = 'deposit') {
if (kind === 'topup') {
return {
meta: divRoutstrTopupInvoiceMeta,
status: divRoutstrTopupInvoiceStatus,
qr: divRoutstrTopupInvoiceQr,
bolt11: taRoutstrTopupInvoiceBolt11,
emptyText: 'No top-up invoice generated yet.'
};
}
return {
meta: divRoutstrDepositInvoiceMeta,
status: divRoutstrDepositInvoiceStatus,
qr: divRoutstrDepositInvoiceQr,
bolt11: taRoutstrDepositInvoiceBolt11,
emptyText: 'No deposit invoice generated yet.'
};
}
function setInvoiceStatus(kind, text) {
const ui = getInvoiceElements(kind);
if (ui.status) ui.status.textContent = String(text || '');
}
function updateInvoiceDisplay(kind, { title = '', bolt11 = '', amountSats = null, expiresAt = null, invoiceId = '' } = {}) {
const ui = getInvoiceElements(kind);
if (ui.meta) {
const lines = [];
if (title) lines.push(title);
if (invoiceId) lines.push(`invoice_id: ${invoiceId}`);
if (typeof amountSats === 'number' && Number.isFinite(amountSats)) lines.push(`amount_sats: ${amountSats}`);
if (expiresAt) {
const expiryMs = Number(expiresAt) * 1000;
if (Number.isFinite(expiryMs)) lines.push(`expires: ${new Date(expiryMs).toLocaleString()}`);
}
ui.meta.textContent = lines.join('\n') || ui.emptyText;
}
if (ui.bolt11) {
ui.bolt11.value = String(bolt11 || '');
}
if (ui.qr) {
ui.qr.innerHTML = '';
const value = String(bolt11 || '').trim();
if (!value) return;
if (typeof QRCode !== 'function') {
ui.qr.textContent = 'QR rendering unavailable';
return;
}
try {
const svg = QRCode({ msg: value, dim: 220, pad: 1, ecl: 'L' });
svg.removeAttributeNS(null, 'width');
svg.removeAttributeNS(null, 'height');
svg.style.width = '100%';
svg.style.maxWidth = '220px';
svg.style.height = 'auto';
ui.qr.appendChild(svg);
} catch (error) {
ui.qr.textContent = `QR error: ${String(error?.message || error)}`;
}
}
}
async function parseJsonSafe(response) {
const text = await response.text();
if (!text) return {};
try {
return JSON.parse(text);
} catch (_error) {
return { raw: text };
}
}
function safeStringify(value) {
try {
return JSON.stringify(value);
} catch (_error) {
return String(value);
}
}
function toErrorDetails(data) {
if (!data || typeof data !== 'object') return '';
if (typeof data?.detail === 'string') return data.detail;
if (data?.detail && typeof data.detail === 'object') return safeStringify(data.detail);
if (typeof data?.error?.message === 'string') return data.error.message;
if (data?.error && typeof data.error === 'object') return safeStringify(data.error);
if (typeof data?.message === 'string') return data.message;
if (typeof data?.raw === 'string') return data.raw;
return safeStringify(data);
}
function buildHttpError(actionLabel, endpoint, response, data) {
const details = toErrorDetails(data) || 'No response body';
const requestId = response?.headers?.get('x-request-id') || response?.headers?.get('request-id') || '';
const requestIdLine = requestId ? `\nrequest_id: ${requestId}` : '';
return new Error(
`${actionLabel} failed\n` +
`HTTP ${response?.status || 0} ${response?.statusText || ''}\n` +
`endpoint: ${endpoint}${requestIdLine}\n` +
`details: ${details}`
);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function pollLightningInvoiceStatus(baseUrl, invoiceId, purposeLabel = 'invoice', invoiceKind = 'deposit') {
const pollId = ++activeInvoicePollId;
const safeInvoiceId = encodeURIComponent(String(invoiceId || '').trim());
if (!safeInvoiceId) return;
const statusEndpoint = `${baseUrl}/v1/balance/lightning/invoice/${safeInvoiceId}/status`;
const maxPolls = 60;
for (let i = 1; i <= maxPolls; i += 1) {
if (pollId !== activeInvoicePollId) return;
try {
const response = await fetch(statusEndpoint);
const data = await parseJsonSafe(response);
if (!response.ok) {
if (i === 1 || i % 6 === 0) {
setInvoiceStatus(invoiceKind, `Waiting for ${purposeLabel} status... ${i}/${maxPolls}\n${toErrorDetails(data) || `HTTP ${response.status}`}`);
}
await sleep(5000);
continue;
}
const status = String(data?.status || '').toLowerCase();
if (status === 'paid') {
const apiKey = String(data?.api_key || '').trim();
if (apiKey && apiKey.startsWith('sk-')) {
inpCfgApiKey.value = apiKey;
aiConfig.api_key = apiKey;
saveAiConfigLocal();
saveAiConfigToUserSettings().catch((e) => console.warn('[html-tv.html] failed to persist paid invoice key:', e));
}
setInvoiceStatus(invoiceKind, `${purposeLabel} paid. ${apiKey ? 'API key loaded into config.' : ''}`.trim());
await getRoutstrBalance();
return;
}
if (status === 'expired') {
setInvoiceStatus(invoiceKind, `${purposeLabel} expired before payment.`);
return;
}
setInvoiceStatus(invoiceKind, `Waiting for ${purposeLabel} payment... ${i}/${maxPolls} (status: ${status || 'pending'})`);
} catch (error) {
if (i === 1 || i % 6 === 0) {
setInvoiceStatus(invoiceKind, `Status poll retry ${i}/${maxPolls}: ${String(error?.message || error)}`);
}
}
await sleep(5000);
}
setInvoiceStatus(invoiceKind, `Stopped polling ${purposeLabel} after timeout.`);
}
async function getRoutstrBalance() {
const baseUrl = getRoutstrBaseUrl();
const apiKey = getAuthToken();
const provider = String(selCfgProvider?.value || aiConfig.provider || '').trim().toLowerCase();
if (!baseUrl) {
setRoutstrBalanceState('Missing Base URL.');
setHeaderProviderBalance('--');
return;
}
try {
if (provider === 'ppq.ai') {
const creditId = String(inpCfgCreditId?.value || aiConfig.credit_id || '').trim();
if (!creditId) {
setRoutstrBalanceState('Missing Credit ID for ppq.ai.');
setHeaderProviderBalance('--');
return;
}
const endpoint = `${baseUrl}/credits/balance`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ credit_id: creditId })
});
const data = await parseJsonSafe(response);
if (!response.ok) {
const details = toErrorDetails(data) || 'No response body';
setRoutstrBalanceState(
`Balance check failed\n` +
`HTTP ${response.status} ${response.statusText}\n` +
`endpoint: ${endpoint}\n` +
`details: ${details}`
);
return;
}
const balance = Number(data?.balance);
const lines = [
`Balance: ${Number.isFinite(balance) ? balance : '--'} credits`,
`credit_id: ${creditId}`,
`Raw: ${safeStringify(data)}`
];
setRoutstrBalanceState(lines.join('\n'));
setHeaderProviderBalance(Number.isFinite(balance) ? `$${balance.toFixed(2)}` : '$--');
return;
}
if (!apiKey) {
setRoutstrBalanceState('Missing API Key / Cashu token.');
setHeaderProviderBalance('--');
return;
}
const endpoint = `${baseUrl}/v1/balance/info`;
const requestHeaders = {
Authorization: `Bearer ${apiKey}`
};
const response = await fetch(endpoint, {
method: 'GET',
headers: requestHeaders
});
const data = await parseJsonSafe(response);
if (!response.ok) {
const details = toErrorDetails(data) || 'No response body';
setRoutstrBalanceState(
`Balance check failed\n` +
`HTTP ${response.status} ${response.statusText}\n` +
`endpoint: ${endpoint}\n` +
`details: ${details}`
);
return;
}
const balance = Number(data?.balance);
const reserved = Number(data?.reserved);
const currency = String(data?.currency || 'sat').trim();
const lines = [
`Balance: ${Number.isFinite(balance) ? balance : '--'} ${currency}`
];
if (Number.isFinite(reserved)) lines.push(`Reserved: ${reserved} ${currency}`);
lines.push(`Raw: ${safeStringify(data)}`);
setRoutstrBalanceState(lines.join('\n'));
setHeaderProviderBalance(Number.isFinite(balance) ? `${Math.floor(balance)} sats` : '-- sats');
} catch (error) {
setRoutstrBalanceState(`Balance check failed: ${String(error?.message || error)}`);
setHeaderProviderBalance('--');
}
}
async function callOpenAICompatibleChat(messages, overrides = {}) {
const endpoint = getChatCompletionsUrl(aiConfig.base_url || '');
const apiKey = String(aiConfig.api_key || '').trim();
const modelOverride = String(overrides?.llm || '').trim();
const model = modelOverride && modelOverride !== 'default'
? modelOverride
: String(aiConfig.model || '').trim();
const maxTokens = Math.max(1, Math.floor(Number(overrides?.max_tokens ?? aiConfig.max_tokens ?? 512)));
const temperature = Number(overrides?.temperature ?? aiConfig.temperature ?? 0.7);
const seed = Number.isFinite(Number(overrides?.seed)) ? Math.floor(Number(overrides.seed)) : null;
if (!endpoint) throw new Error('Missing base URL in sidebar config');
if (!apiKey) throw new Error('Missing API key in sidebar config');
if (!model) throw new Error('Missing model in sidebar config');
const requestHeaders = {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
};
const requestBody = {
model,
messages,
max_tokens: maxTokens,
temperature,
stream: false
};
if (Number.isFinite(seed)) requestBody.seed = seed;
console.log('[html-tv.html] chat request', {
endpoint,
method: 'POST',
headers: requestHeaders,
body: requestBody
});
const response = await fetch(endpoint, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(requestBody)
});
const data = await parseJsonSafe(response);
if (!response.ok) {
const headersObj = {};
response.headers.forEach((value, key) => {
headersObj[key] = value;
});
const requestId = response.headers.get('x-request-id') || response.headers.get('request-id') || '';
const details = toErrorDetails(data) || 'No response body';
const bodyDump = safeStringify(data);
console.error('[html-tv.html] chat request failed', {
request: {
endpoint,
method: 'POST',
headers: requestHeaders,
body: requestBody
},
response: {
status: response.status,
statusText: response.statusText,
requestId,
headers: headersObj,
body: data
}
});
throw new Error(
`HTTP ${response.status} ${response.statusText}\n` +
`endpoint: ${endpoint}\n` +
`${requestId ? `request_id: ${requestId}\n` : ''}` +
`details: ${details}\n` +
`response_headers: ${safeStringify(headersObj)}\n` +
`response_body: ${bodyDump}`
);
}
const text = String(data?.choices?.[0]?.message?.content || '').trim();
return { content: text || '(empty response)' };
}
function setSendingState(value) {
isSending = Boolean(value);
if (btnAiRun) btnAiRun.disabled = isSending;
if (btnAiSaveSkill) btnAiSaveSkill.disabled = isSending;
taAiPrompt.disabled = isSending;
setViewerLoading(isSending);
}
async function saveSkillOnly() {
const slug = normalizeSlug(inpSkillSlug?.value || '');
const description = String(inpSkillDescription?.value || '').trim();
const template = String(taSkillTemplate?.value || '').trim();
const userInput = String(taAiPrompt?.value || '').trim();
const temperature = Number(inpSkillTemperature?.value || 0);
const maxTokens = Math.max(100, Math.floor(Number(inpSkillMaxTokens?.value || 4000)));
const llm = String(selAiModel?.value || aiConfig.model || 'default').trim() || 'default';
const seedRaw = String(inpSkillSeed?.value ?? '0').trim();
const seed = Number.isFinite(Number(seedRaw)) ? Math.floor(Number(seedRaw)) : 0;
if (!slug) return setStatus('Skill slug is required.');
if (!description) return setStatus('Skill description is required.');
if (!template) return setStatus('Skill template is required.');
if (!userInput) return setStatus('User input is required.');
const templateToSave = buildStoredTemplateWithUserInput(template, userInput);
const skillEvent = buildSkillEvent(slug, description, templateToSave, temperature, maxTokens, llm, seed);
await publishEvent(skillEvent);
upsertSkillFromEvent({ ...skillEvent, pubkey: currentPubkey || '', tags: skillEvent.tags });
setStatus(`Saved skill: ${slug}`);
}
async function sendPrompt() {
if (isSending) return;
const slug = normalizeSlug(inpSkillSlug?.value || '');
const description = String(inpSkillDescription?.value || '').trim();
const template = String(taSkillTemplate?.value || '').trim();
const userInput = String(taAiPrompt?.value || '').trim();
const temperature = Number(inpSkillTemperature?.value || 0);
const maxTokens = Math.max(100, Math.floor(Number(inpSkillMaxTokens?.value || 4000)));
const llm = String(selAiModel?.value || aiConfig.model || 'default').trim() || 'default';
const seedRaw = String(inpSkillSeed?.value ?? '0').trim();
const seed = Number.isFinite(Number(seedRaw)) ? Math.floor(Number(seedRaw)) : 0;
console.log('[html-tv.html] sendPrompt:start', { slug, hasDescription: Boolean(description), hasTemplate: Boolean(template), hasUserInput: Boolean(userInput), temperature, maxTokens, llm, seed });
if (!slug) {
console.warn('[html-tv.html] sendPrompt:blocked missing slug');
return setStatus('Skill slug is required.');
}
if (!description) {
console.warn('[html-tv.html] sendPrompt:blocked missing description');
return setStatus('Skill description is required.');
}
if (!template) {
console.warn('[html-tv.html] sendPrompt:blocked missing template');
return setStatus('Skill template is required.');
}
if (!userInput) {
console.warn('[html-tv.html] sendPrompt:blocked missing user input');
return setStatus('User input is required.');
}
const parsed = parseSkillTemplate(template, userInput);
const assistant = addMessage('assistant', '', { typing: true });
setSendingState(true);
setStatus('Running skill and rendering page...');
console.log('[html-tv.html] sendPrompt:calling-llm');
try {
const result = await callOpenAICompatibleChat(
[
{ role: 'system', content: parsed.system || DEFAULT_TV_SYSTEM_PROMPT },
{ role: 'user', content: parsed.user || userInput }
],
{
temperature,
max_tokens: maxTokens,
llm,
seed
}
);
const normalizedHtml = extractHtmlFromResponse(String(result?.content || ''));
renderViewerHtml(normalizedHtml);
const page = {
id: assistant.id,
html: normalizedHtml,
createdAt: nowTs(),
title: extractPageTitleFromHtml(normalizedHtml, slug),
blossomUrl: ''
};
renderedPages.push(page);
selectedRenderedPageId = page.id;
renderRenderedPageActions();
setStatus(`Rendered skill: ${slug}`);
} catch (error) {
console.error('[html-tv.html] sendPrompt:error', error);
setStatus(`Request failed: ${String(error?.message || error || 'Unknown error')}`);
} finally {
setSendingState(false);
getRoutstrBalance().catch((error) => {
console.warn('[html-tv.html] post-message balance refresh failed:', error);
});
}
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
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');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
if (isNavOpen) closeNav();
else openNav();
}
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = currentPubkey ? `${currentPubkey.slice(0, 8)}...` : '';
} catch (error) {
console.error('[html-tv.html] footer update failed:', error);
}
};
const Logout = async () => {
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);
}
}
location.reload(true);
};
function bindUiEvents() {
if (btnSkillFilterAll) {
btnSkillFilterAll.addEventListener('click', () => {
skillFilterMode = 'all';
refreshSkills();
});
}
if (btnSkillFilterMy) {
btnSkillFilterMy.addEventListener('click', () => {
skillFilterMode = 'my';
refreshSkills();
});
}
if (btnSkillRefresh) {
btnSkillRefresh.addEventListener('click', () => refreshSkills());
}
if (btnSkillNew) {
btnSkillNew.addEventListener('click', () => createNewSkill());
}
if (btnSkillDelete) {
btnSkillDelete.addEventListener('click', () => {
deleteSelectedSkill();
});
}
if (btnAiSaveRenderedPage) {
btnAiSaveRenderedPage.addEventListener('click', async () => {
if (!selectedRenderedPageId) return;
await saveAssistantPageToBlossom(selectedRenderedPageId);
});
}
if (btnAiPopoutPreview) {
btnAiPopoutPreview.addEventListener('click', () => {
popoutPreviewWindow();
});
}
if (lnkAiLaunchRenderedPage) {
lnkAiLaunchRenderedPage.addEventListener('click', (event) => {
if (!selectedRenderedPageId) event.preventDefault();
});
}
if (btnAiRun) {
btnAiRun.addEventListener('click', () => {
console.log('[html-tv.html] ui:Run Skill clicked');
sendPrompt();
});
}
if (btnAiSaveSkill) {
btnAiSaveSkill.addEventListener('click', async () => {
console.log('[html-tv.html] ui:Save Skill clicked');
try {
await saveSkillOnly();
} catch (error) {
console.error('[html-tv.html] saveSkillOnly:error', error);
setStatus(`Save failed: ${String(error?.message || error || 'Unknown error')}`);
}
});
}
selAiModel.addEventListener('change', () => {
selectModel(selAiModel.value || '');
});
btnCfgSave.addEventListener('click', async () => {
aiConfig.provider = String(selCfgProvider.value || '').trim();
aiConfig.base_url = String(inpCfgBaseUrl.value || '').trim();
aiConfig.api_key = String(inpCfgApiKey.value || '').trim();
aiConfig.credit_id = String(inpCfgCreditId?.value || '').trim();
aiConfig.model = String(selCfgModel.value || '').trim();
aiConfig.max_tokens = Math.max(1, Math.floor(Number(inpCfgMaxTokens.value || 512)));
aiConfig.temperature = Number(inpCfgTemperature.value || 0.7);
syncSelectedProviderProfile();
saveAiConfigLocal();
try {
await saveAiConfigToUserSettings();
setStatus('AI endpoint config saved to user-settings.');
} catch (error) {
console.warn('[html-tv.html] failed to persist AI config to user-settings:', error);
setStatus('AI endpoint config saved locally (user-settings sync failed).');
}
applyAiConfigToHeader();
});
fetchModels = async () => {
const baseUrl = normalizeBaseUrl(inpCfgBaseUrl.value || aiConfig.base_url);
const apiKey = inpCfgApiKey.value || aiConfig.api_key;
if (!baseUrl || !apiKey) {
fetchedModels = [];
renderModelDropdown();
return;
}
try {
const response = await fetch(`${baseUrl}/v1/models`, {
headers: {
Authorization: `Bearer ${apiKey}`
}
});
if (!response.ok) throw new Error('Failed to fetch models');
const data = await response.json();
fetchedModels = data.data || [];
renderModelDropdown();
} catch (error) {
console.warn('[html-tv.html] fetchModels failed:', error);
fetchedModels = [];
renderModelDropdown();
}
};
if (inpAiModelFilter) inpAiModelFilter.addEventListener('input', renderModelDropdown);
if (inpCfgModelFilter) inpCfgModelFilter.addEventListener('input', renderModelDropdown);
selCfgModel.addEventListener('change', () => selectModel(selCfgModel.value || ''));
btnCfgFav.addEventListener('click', () => {
const current = selCfgModel.value;
if (!current) return;
let favs = [...(aiConfig.favorites || [])];
if (favs.includes(current)) {
favs = favs.filter(f => f !== current);
} else {
favs.push(current);
}
aiConfig.favorites = favs;
syncSelectedProviderProfile();
renderModelDropdown();
saveAiConfigLocal();
saveAiConfigToUserSettings().catch(e => console.warn('[html-tv.html] failed to save favorites:', e));
});
if (btnAiModelDropdown) {
btnAiModelDropdown.addEventListener('click', (event) => {
event.stopPropagation();
const willOpen = !ddAiModel.classList.contains('open');
closeAllCustomDropdowns();
setDropdownOpen(ddAiModel, willOpen);
if (willOpen && inpAiModelFilter) {
inpAiModelFilter.value = '';
renderModelDropdown();
inpAiModelFilter.focus();
}
});
}
if (btnCfgModelDropdown) {
btnCfgModelDropdown.addEventListener('click', (event) => {
event.stopPropagation();
const willOpen = !ddCfgModel.classList.contains('open');
closeAllCustomDropdowns();
setDropdownOpen(ddCfgModel, willOpen);
if (willOpen && inpCfgModelFilter) {
inpCfgModelFilter.value = '';
renderModelDropdown();
inpCfgModelFilter.focus();
}
});
}
document.addEventListener('click', () => {
closeAllCustomDropdowns();
});
if (btnCfgCopyApiKey) {
btnCfgCopyApiKey.addEventListener('click', async () => {
const value = String(inpCfgApiKey.value || '').trim();
if (!value) {
setStatus('Nothing to copy from API Key / Cashu Token field.');
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
} else {
inpCfgApiKey.focus();
inpCfgApiKey.select();
const ok = document.execCommand('copy');
if (!ok) throw new Error('Copy command failed');
}
setStatus('Copied API Key / Cashu Token to clipboard.');
} catch (error) {
setStatus(`Copy failed: ${String(error?.message || error)}`);
}
});
}
selCfgProvider.addEventListener('change', () => {
const val = selCfgProvider.value;
if (val === 'custom') {
aiConfig.provider = 'custom';
saveAiConfigLocal();
renderProviderDropdown();
updateProviderSections();
return;
}
selectProvider(val);
});
selAiProvider.addEventListener('change', () => {
const val = selAiProvider.value;
selectProvider(val);
});
inpCfgBaseUrl.addEventListener('blur', fetchModels);
inpCfgApiKey.addEventListener('blur', fetchModels);
setTimeout(fetchModels, 1000);
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) divSvgHam.addEventListener('click', toggleNav);
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('[html-tv.html] Logout failed:', error);
}
});
}
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function bindSkillEventListeners() {
window.addEventListener('ndkEvent', (event) => {
const evt = event?.detail;
if (!evt || evt.kind !== 31123) return;
upsertSkillFromEvent(evt);
});
window.addEventListener('ndkEose', (event) => {
const eoseSubId = event?.detail?.subId || null;
if (skillSubId && eoseSubId && eoseSubId !== skillSubId) return;
setStatus(skills.length > 0 ? '' : 'No skills found.');
});
}
function bindRelayActivityListeners() {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail || {};
if (relayUrl && activity) setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
if (relayUrl && activity) setRelayActivityState(relayUrl, activity);
}
});
}
(async function main() {
const versionInfo = await getVersion();
console.log(`[html-tv.html ${versionInfo.VERSION}] Loading...`);
await updateVersionDisplay();
try {
initHamburgerMenu();
bindUiEvents();
bindSkillEventListeners();
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
loadAiConfig();
try {
const settings = await getUserSettings();
aiConfig = mergeAiConfigFromSettings(aiConfig, settings?.global_llm || settings?.ai || {});
saveAiConfigLocal();
} catch (error) {
console.warn('[html-tv.html] getUserSettings failed, using local AI config:', error);
}
onUserSettings((settings) => {
try {
aiConfig = mergeAiConfigFromSettings(aiConfig, settings?.global_llm || settings?.ai || {});
if (!aiConfig.provider && aiConfig.base_url) aiConfig.provider = aiConfig.base_url;
if (!aiConfig.base_url && aiConfig.provider) aiConfig.base_url = aiConfig.provider;
saveAiConfigLocal();
renderAiConfigForm();
fetchModels();
applyAiConfigToHeader();
} catch (error) {
console.warn('[html-tv.html] failed to apply user-settings update:', error);
}
});
renderAiConfigForm();
applyAiConfigToHeader();
updateProviderSections();
loadConversations();
renderConversationList();
renderCurrentConversation();
renderRenderedPageActions();
bindRelayActivityListeners();
getRoutstrBalance().catch((error) => {
console.warn('[html-tv.html] initial Routstr balance fetch failed:', error);
});
updateIntervalId = setInterval(UpdateFooter, 1000);
await updateVersionDisplay();
} catch (error) {
console.error('[html-tv.html] initialization failed:', error);
setStatus(`Initialization failed: ${error?.message || error}`);
await updateVersionDisplay();
}
})();
</script>
</body>
</html>