Files
client/www/ai-tv.html
T

2898 lines
102 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>AI 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;
}
#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: 52px;
max-height: 180px;
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%;
}
#divAiPendingAttachments {
display: none;
flex-wrap: wrap;
gap: 6px;
}
#divAiPendingAttachments.active {
display: flex;
}
.aiAttachmentChip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--muted-color);
border-radius: 14px;
padding: 2px 8px 2px 4px;
font-size: 73%;
color: var(--primary-color);
background: color-mix(in srgb, var(--secondary-color) 90%, var(--muted-color) 10%);
}
.aiAttachmentThumb {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: cover;
border: 1px solid var(--muted-color);
}
.aiAttachmentRemove {
border: none;
background: transparent;
color: var(--muted-color);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
}
.aiAttachmentRemove:hover {
color: var(--accent-color);
}
#divAiSystemPromptSection {
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 8px;
background: color-mix(in srgb, var(--secondary-color) 90%, var(--muted-color) 10%);
}
#btnAiSystemPromptToggle {
width: 100%;
border: 1px solid var(--muted-color);
border-radius: 6px;
background: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 75%;
text-align: left;
padding: 6px 8px;
cursor: pointer;
}
#btnAiSystemPromptToggle:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
#divAiSystemPromptBody {
display: none;
margin-top: 8px;
}
#divAiSystemPromptBody.open {
display: block;
}
#btnAiAttach {
min-width: 90px;
}
#btnAiSend {
min-width: 100px;
}
#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">AI TV</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divAiLayout">
<div id="divAiConversationsPane">
<div id="divAiConversationsTitle">Past Pages</div>
<div id="divAiConversationsList"></div>
<button id="btnAiNewChat" class="btn" style="width: 100%;">+ New Session</button>
</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>
</div>
</div>
<div id="divAiInputArea">
<div id="divAiPromptColumn">
<div id="divAiSystemPromptSection">
<button id="btnAiSystemPromptToggle" type="button">▶ System Prompt</button>
<div id="divAiSystemPromptBody">
<textarea id="taAiSystemPrompt" class="aiTextarea"></textarea>
</div>
</div>
<textarea id="taAiPrompt" placeholder="Describe the page you want to generate..."></textarea>
<div id="divAiPendingAttachments"></div>
<input id="inpAiImageUpload" type="file" accept="image/*" multiple style="display:none;" />
</div>
<button id="btnAiAttach" class="btn" type="button">Image</button>
<button id="btnAiSend" class="btn">Send</button>
</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="4096" />
</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 session)</div>
<textarea id="taAiSystemPromptSidebar" class="aiTextarea" readonly>Use the in-viewer System Prompt panel.</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
} 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 STORAGE_KEY = 'ai_chat_conversations_v1';
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.';
let selectedConversationId = null;
let selectedPageMessageId = null;
let conversations = [];
let activeAssistantMessageId = null;
let isSending = false;
let aiConfig = {
provider: 'ppq.ai',
api_key: '',
credit_id: '',
model: 'claude-haiku-4.5',
base_url: 'https://api.ppq.ai',
max_tokens: 4096,
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 pendingImageAttachments = [];
let aiInputDragDepth = 0;
let isProcessingAttachments = false;
const savingPageMessageIds = new Set();
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 btnAiSystemPromptToggle = document.getElementById('btnAiSystemPromptToggle');
const divAiSystemPromptBody = document.getElementById('divAiSystemPromptBody');
const divAiInputArea = document.getElementById('divAiInputArea');
const divAiPendingAttachments = document.getElementById('divAiPendingAttachments');
const inpAiImageUpload = document.getElementById('inpAiImageUpload');
const btnAiAttach = document.getElementById('btnAiAttach');
const btnAiSend = document.getElementById('btnAiSend');
const btnAiNewChat = document.getElementById('btnAiNewChat');
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 setStatus(text) {
divAiStatus.textContent = String(text || '');
}
function normalizeMessageAttachments(attachments) {
if (!Array.isArray(attachments)) return [];
return attachments
.map((att) => {
const mimeType = String(att?.mimeType || '').trim();
const dataUrl = String(att?.dataUrl || '').trim();
if (!mimeType || !dataUrl) return null;
if (!dataUrl.startsWith('data:image/')) return null;
return {
id: String(att?.id || uid()),
name: String(att?.name || 'image').trim() || 'image',
mimeType,
dataUrl
};
})
.filter(Boolean);
}
function getAttachmentPreviewName(att) {
const name = String(att?.name || '').trim();
if (name) return name;
const mime = String(att?.mimeType || '').trim();
const ext = mime.startsWith('image/') ? mime.slice('image/'.length) : 'image';
return `image.${ext || 'bin'}`;
}
function renderPendingAttachments() {
if (!divAiPendingAttachments) return;
if (!Array.isArray(pendingImageAttachments) || pendingImageAttachments.length === 0) {
divAiPendingAttachments.classList.remove('active');
divAiPendingAttachments.innerHTML = '';
return;
}
const html = pendingImageAttachments
.map((att, index) => {
const name = escapeHtml(getAttachmentPreviewName(att));
const src = escapeHtml(String(att?.dataUrl || ''));
return `
<div class="aiAttachmentChip" title="${name}">
<img class="aiAttachmentThumb" src="${src}" alt="attachment" />
<span>${name}</span>
<button class="aiAttachmentRemove" type="button" aria-label="Remove attachment" data-attachment-index="${index}">×</button>
</div>
`;
})
.join('');
divAiPendingAttachments.innerHTML = html;
divAiPendingAttachments.classList.add('active');
Array.from(divAiPendingAttachments.querySelectorAll('.aiAttachmentRemove[data-attachment-index]')).forEach((btn) => {
btn.addEventListener('click', () => {
const idx = Number(btn.getAttribute('data-attachment-index'));
if (Number.isFinite(idx) && idx >= 0 && idx < pendingImageAttachments.length) {
pendingImageAttachments.splice(idx, 1);
renderPendingAttachments();
setStatus(`Removed attachment (${pendingImageAttachments.length} remaining).`);
}
});
});
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
async function appendPendingImageFiles(filesLike) {
const files = Array.from(filesLike || [])
.filter((file) => file && file.size > 0)
.filter((file) => String(file.type || '').startsWith('image/'));
if (files.length === 0) {
setStatus('No image files found to attach.');
return;
}
if (isProcessingAttachments) return;
isProcessingAttachments = true;
setStatus(`Preparing ${files.length} image${files.length > 1 ? 's' : ''}...`);
try {
const next = [];
for (const file of files) {
const dataUrl = await readFileAsDataUrl(file);
if (!dataUrl.startsWith('data:image/')) continue;
next.push({
id: uid(),
name: String(file.name || 'image').trim() || 'image',
mimeType: String(file.type || 'image/png').trim(),
dataUrl
});
}
if (next.length === 0) {
setStatus('Could not process selected images.');
return;
}
pendingImageAttachments = [...pendingImageAttachments, ...next];
renderPendingAttachments();
setStatus(`Attached ${next.length} image${next.length > 1 ? 's' : ''}.`);
} catch (error) {
setStatus(`Image attach failed: ${String(error?.message || error)}`);
} finally {
isProcessingAttachments = false;
}
}
function getConversationIndexById(id) {
return conversations.findIndex((conv) => conv.id === id);
}
function getCurrentConversation() {
const index = getConversationIndexById(selectedConversationId);
if (index < 0) return null;
return conversations[index];
}
function saveConversations() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
}
function loadConversations() {
let loaded = [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
loaded = Array.isArray(JSON.parse(raw || '[]')) ? JSON.parse(raw || '[]') : [];
} catch (_error) {
loaded = [];
}
conversations = loaded
.map((conv) => ({
id: String(conv?.id || uid()),
title: String(conv?.title || 'New Chat'),
modelId: String(conv?.modelId || ''),
systemPrompt: String(conv?.systemPrompt || DEFAULT_TV_SYSTEM_PROMPT),
messages: (Array.isArray(conv?.messages) ? conv.messages : []).map((msg) => ({
...msg,
attachments: normalizeMessageAttachments(msg?.attachments)
})),
createdAt: Number(conv?.createdAt || nowTs()),
updatedAt: Number(conv?.updatedAt || nowTs())
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
if (conversations.length === 0) {
createConversation();
} else {
selectedConversationId = conversations[0].id;
}
}
function createConversation() {
const convo = {
id: uid(),
title: 'New Session',
modelId: '',
systemPrompt: DEFAULT_TV_SYSTEM_PROMPT,
messages: [],
createdAt: nowTs(),
updatedAt: nowTs()
};
conversations.unshift(convo);
selectedConversationId = convo.id;
selectedPageMessageId = null;
saveConversations();
renderConversationList();
renderCurrentConversation();
}
function updateConversation(patch = {}) {
const index = getConversationIndexById(selectedConversationId);
if (index < 0) return;
const prev = conversations[index];
const next = {
...prev,
...patch,
updatedAt: nowTs()
};
conversations[index] = next;
conversations.sort((a, b) => b.updatedAt - a.updatedAt);
saveConversations();
renderConversationList();
}
function ensureConversationTitle(convo) {
if (!convo) return;
if (convo.title && convo.title !== 'New Session') return;
const firstUser = convo.messages.find((m) => m.role === 'user');
if (!firstUser) return;
const title = String(firstUser.content || '').trim().slice(0, 48);
convo.title = title || 'New Session';
}
function formatMessageTime(ts) {
const d = new Date(Number(ts || nowTs()));
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
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>AI 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 getAssistantPages(convo) {
if (!convo) return [];
return (Array.isArray(convo.messages) ? convo.messages : [])
.filter((msg) => msg && msg.role === 'assistant' && !msg.typing && String(msg.content || '').trim())
.map((msg, index) => {
const html = extractHtmlFromResponse(msg.content || '');
const title = extractPageTitleFromHtml(html, `Page ${index + 1}`);
return {
id: String(msg.id || ''),
createdAt: Number(msg.createdAt || nowTs()),
html,
title,
blossomUrl: String(msg.blossomUrl || '').trim()
};
});
}
function setViewerLoading(isActive) {
if (!divAiViewerLoading) return;
divAiViewerLoading.classList.toggle('active', Boolean(isActive));
}
function renderViewerHtml(html) {
if (!iframeAiTv) return;
iframeAiTv.srcdoc = String(html || '');
}
function getEmptyViewerHtml(text = 'Send a prompt to generate a page...') {
return `<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>AI 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>`;
}
async function saveAssistantPageToBlossom(messageId) {
const convo = getCurrentConversation();
if (!convo || !messageId) return;
const msg = (convo.messages || []).find((m) => String(m?.id || '') === String(messageId));
if (!msg || msg.role !== 'assistant') return;
const defaultServer = getDefaultBlossomServer();
if (!defaultServer) {
setStatus('No Blossom server configured. Add one in the sidebar first.');
return;
}
const pageHtml = extractHtmlFromResponse(String(msg.content || ''));
const safeTitle = extractPageTitleFromHtml(pageHtml, 'ai-tv-page')
.toLowerCase()
.replace(/[^a-z0-9-_]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'ai-tv-page';
const fileName = `${safeTitle}.html`;
const file = new File([pageHtml], fileName, { type: 'text/html;charset=utf-8' });
try {
savingPageMessageIds.add(String(messageId));
renderConversationList();
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}`;
patchMessage(String(messageId), { blossomUrl });
setStatus(`Saved to Blossom: ${blossomUrl}`);
} catch (error) {
setStatus(`Blossom save failed: ${String(error?.message || error)}`);
} finally {
savingPageMessageIds.delete(String(messageId));
renderConversationList();
}
}
function renderConversationList() {
const convo = getCurrentConversation();
const pages = getAssistantPages(convo);
const rows = pages
.map((page) => {
const active = page.id === selectedPageMessageId ? 'active' : '';
const launchHtml = page.blossomUrl
? `<a class="aiPageLaunchLink" href="${escapeHtml(page.blossomUrl)}" target="_blank" rel="noopener noreferrer">Launch</a>`
: '';
const isSaving = savingPageMessageIds.has(page.id);
return `
<div class="aiConversationItem ${active}" data-message-id="${escapeHtml(page.id)}">
<div style="flex-grow: 1; overflow: hidden;">
<div class="aiConversationHeader">
<div class="aiConversationTitle">${escapeHtml(page.title)}</div>
<button class="aiDeleteConvBtn" title="Delete Page" aria-label="Delete Page">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div class="aiConversationPreview">${escapeHtml(formatMessageTime(page.createdAt))}</div>
<div class="aiPageActions">
<button class="aiPageActionBtn aiSavePageBtn" data-message-id="${escapeHtml(page.id)}" type="button" ${isSaving ? 'disabled' : ''}>${isSaving ? 'Saving...' : 'Save to Blossom'}</button>
${launchHtml}
</div>
</div>
</div>
`;
})
.join('');
divAiConversationsList.innerHTML = rows || '<div class="aiConversationPreview">No pages yet</div>';
Array.from(divAiConversationsList.querySelectorAll('.aiConversationItem[data-message-id]')).forEach((el) => {
el.addEventListener('click', () => {
selectedPageMessageId = String(el.getAttribute('data-message-id') || '');
renderConversationList();
renderCurrentConversation();
});
const saveBtn = el.querySelector('.aiSavePageBtn[data-message-id]');
if (saveBtn) {
saveBtn.addEventListener('click', async (event) => {
event.stopPropagation();
const messageId = String(saveBtn.getAttribute('data-message-id') || '');
await saveAssistantPageToBlossom(messageId);
});
}
const launchLink = el.querySelector('.aiPageLaunchLink');
if (launchLink) {
launchLink.addEventListener('click', (event) => {
event.stopPropagation();
});
}
const delBtn = el.querySelector('.aiDeleteConvBtn');
if (delBtn) {
delBtn.addEventListener('click', (event) => {
event.stopPropagation();
const messageId = String(el.getAttribute('data-message-id') || '');
const current = getCurrentConversation();
if (!current || !messageId) return;
current.messages = (current.messages || []).filter((msg) => String(msg.id || '') !== messageId);
const remainingPages = getAssistantPages(current);
if (selectedPageMessageId === messageId) {
selectedPageMessageId = remainingPages.length > 0 ? remainingPages[remainingPages.length - 1].id : null;
}
updateConversation({ messages: current.messages, title: current.title });
renderCurrentConversation();
});
}
});
}
function deleteConversation(id) {
if (!confirm('Delete this session?')) return;
conversations = conversations.filter(c => c.id !== id);
if (selectedConversationId === id) {
selectedConversationId = conversations.length > 0 ? conversations[0].id : null;
}
if (conversations.length === 0) {
createConversation();
} else {
saveConversations();
renderConversationList();
renderCurrentConversation();
}
}
function renderCurrentConversation() {
const convo = getCurrentConversation();
if (!convo) {
renderViewerHtml(getEmptyViewerHtml());
return;
}
taAiSystemPrompt.value = convo.systemPrompt || DEFAULT_TV_SYSTEM_PROMPT;
const taAiSystemPromptSidebar = document.getElementById('taAiSystemPromptSidebar');
if (taAiSystemPromptSidebar) {
taAiSystemPromptSidebar.value = taAiSystemPrompt.value;
}
if (convo.modelId) selAiModel.value = convo.modelId;
const pages = getAssistantPages(convo);
if (!selectedPageMessageId || !pages.some((page) => page.id === selectedPageMessageId)) {
selectedPageMessageId = pages.length > 0 ? pages[pages.length - 1].id : null;
}
const selectedPage = pages.find((page) => page.id === selectedPageMessageId) || null;
if (selectedPage) {
renderViewerHtml(selectedPage.html);
} else {
renderViewerHtml(getEmptyViewerHtml());
}
renderConversationList();
}
function addMessage(role, content, options = {}) {
const convo = getCurrentConversation();
if (!convo) return null;
const msg = {
id: uid(),
role,
content: String(content || ''),
typing: Boolean(options.typing),
sats: Number(options.sats || 0),
attachments: normalizeMessageAttachments(options.attachments),
createdAt: nowTs()
};
convo.messages.push(msg);
ensureConversationTitle(convo);
updateConversation({ messages: convo.messages, title: convo.title });
renderCurrentConversation();
return msg;
}
function patchMessage(messageId, patch = {}) {
const convo = getCurrentConversation();
if (!convo) return;
const idx = convo.messages.findIndex((m) => m.id === messageId);
if (idx < 0) return;
convo.messages[idx] = {
...convo.messages[idx],
...patch
};
updateConversation({ messages: convo.messages });
renderCurrentConversation();
}
function conversationToApiMessages(convo) {
const out = [];
const sys = String(convo.systemPrompt || '').trim();
if (sys) {
out.push({ role: 'system', content: sys });
}
for (const msg of convo.messages) {
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const attachments = normalizeMessageAttachments(msg?.attachments);
if (attachments.length === 0) {
out.push({ role: msg.role, content: String(msg.content || '') });
continue;
}
const parts = [];
const text = String(msg.content || '').trim();
if (text) {
parts.push({ type: 'text', text });
}
for (const att of attachments) {
parts.push({
type: 'image_url',
image_url: {
url: att.dataUrl
}
});
}
out.push({ role: msg.role, content: parts });
}
return out;
}
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 ?? 4096))),
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 ?? 4096),
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 || 4096))),
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) : 4096;
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('[ai.html] failed to persist provider to user-settings:', error);
});
fetchModels();
applyAiConfigToHeader();
updateProviderSections();
getRoutstrBalance().catch((error) => {
console.warn('[ai.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('[ai.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 || 4096);
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('[ai.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) {
const endpoint = getChatCompletionsUrl(aiConfig.base_url || '');
const apiKey = String(aiConfig.api_key || '').trim();
const model = String(aiConfig.model || '').trim();
const maxTokens = Math.max(1, Math.floor(Number(aiConfig.max_tokens || 4096)));
const temperature = Number(aiConfig.temperature ?? 0.7);
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
};
console.log('[ai.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('[ai.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 finishReason = String(data?.choices?.[0]?.finish_reason || '').trim().toLowerCase();
const text = String(data?.choices?.[0]?.message?.content || '').trim();
const content = text || '(empty response)';
const truncationWarning = finishReason === 'length'
? '\n\n⚠️ Response was truncated due to max_tokens limit.'
: '';
return { content: `${content}${truncationWarning}` };
}
function setSendingState(value) {
isSending = Boolean(value);
btnAiSend.disabled = isSending;
taAiPrompt.disabled = isSending;
if (btnAiAttach) btnAiAttach.disabled = isSending;
if (inpAiImageUpload) inpAiImageUpload.disabled = isSending;
setViewerLoading(isSending);
}
async function sendPrompt() {
if (isSending) return;
const prompt = String(taAiPrompt.value || '').trim();
const attachmentsToSend = normalizeMessageAttachments(pendingImageAttachments);
if (!prompt && attachmentsToSend.length === 0) return;
const convo = getCurrentConversation();
if (!convo) return;
convo.modelId = String(aiConfig.model || '').trim();
convo.systemPrompt = String(taAiSystemPrompt.value || DEFAULT_TV_SYSTEM_PROMPT).trim() || DEFAULT_TV_SYSTEM_PROMPT;
updateConversation({
modelId: convo.modelId,
systemPrompt: convo.systemPrompt
});
addMessage('user', prompt, { attachments: attachmentsToSend });
taAiPrompt.value = '';
pendingImageAttachments = [];
renderPendingAttachments();
const assistant = addMessage('assistant', '', { typing: true });
activeAssistantMessageId = assistant?.id || null;
setSendingState(true);
setStatus('Generating page...');
try {
const apiMessages = conversationToApiMessages(convo);
const result = await callOpenAICompatibleChat(apiMessages);
const normalizedHtml = extractHtmlFromResponse(String(result?.content || ''));
if (activeAssistantMessageId) {
patchMessage(activeAssistantMessageId, {
content: normalizedHtml,
typing: false,
sats: 0
});
selectedPageMessageId = activeAssistantMessageId;
}
renderCurrentConversation();
setStatus(`Rendered via ${aiConfig.base_url}`);
} catch (error) {
const text = `Request failed: ${String(error?.message || error || 'Unknown error')}`;
if (activeAssistantMessageId) {
patchMessage(activeAssistantMessageId, {
role: 'system',
content: text,
typing: false
});
}
setStatus(text);
} finally {
activeAssistantMessageId = null;
setSendingState(false);
getRoutstrBalance().catch((error) => {
console.warn('[ai.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('[ai.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() {
btnAiNewChat.addEventListener('click', () => {
createConversation();
});
if (btnRoutstrGetBalance) {
btnRoutstrGetBalance.addEventListener('click', async () => {
setRoutstrOpsStatus('Fetching balance...');
try {
await getRoutstrBalance();
setRoutstrOpsStatus('Balance updated.');
} catch (error) {
setRoutstrOpsStatus(`Get balance failed: ${String(error?.message || error)}`);
}
});
}
if (btnRoutstrCreateInvoice) {
btnRoutstrCreateInvoice.addEventListener('click', async () => {
const baseUrl = getRoutstrBaseUrl();
const apiKey = getAuthToken();
const provider = String(selCfgProvider?.value || aiConfig.provider || '').trim().toLowerCase();
const amountSats = Math.max(1, Math.floor(Number(inpRoutstrDepositSats?.value || 0)));
if (!baseUrl) return setRoutstrOpsStatus('Missing Base URL.');
setRoutstrOpsStatus('Creating deposit invoice...');
try {
if (provider === 'ppq.ai') {
if (!apiKey) return setRoutstrOpsStatus('ppq.ai invoice creation requires API key.');
const endpoint = `${baseUrl}/topup/create/btc-lightning`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount: amountSats, currency: 'SATS' })
});
const data = await parseJsonSafe(response);
if (!response.ok) throw buildHttpError('Create ppq.ai top-up invoice', endpoint, response, data);
const invoiceId = String(data?.invoice_id || data?.id || data?.btcpay_invoice_id || '').trim();
const bolt11 = String(data?.bolt11 || data?.payment_request || data?.lightning_invoice || '').trim();
const paymentUrl = String(data?.payment_url || data?.checkout_url || data?.invoice_url || '').trim();
updateInvoiceDisplay('deposit', {
title: 'ppq.ai top-up invoice created',
bolt11: bolt11 || paymentUrl,
amountSats,
expiresAt: data?.expires_at,
invoiceId
});
setInvoiceStatus('deposit', paymentUrl && !bolt11 ? `Open payment URL:\n${paymentUrl}` : 'Waiting for payment...');
setRoutstrOpsStatus(
`ppq.ai top-up invoice created (${amountSats} SATS)` +
`${invoiceId ? `\ninvoice_id: ${invoiceId}` : ''}` +
`${bolt11 ? `\nbolt11: ${bolt11}` : ''}` +
`${paymentUrl ? `\npayment_url: ${paymentUrl}` : ''}`
);
return;
}
const endpoint = `${baseUrl}/v1/balance/lightning/invoice`;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount_sats: amountSats, purpose: 'create' })
});
const data = await parseJsonSafe(response);
if (!response.ok) throw buildHttpError('Create deposit invoice', endpoint, response, data);
const invoiceId = String(data?.invoice_id || '').trim();
const bolt11 = String(data?.bolt11 || '').trim();
updateInvoiceDisplay('deposit', {
title: `Deposit invoice created`,
bolt11,
amountSats,
expiresAt: data?.expires_at,
invoiceId
});
setInvoiceStatus('deposit', 'Waiting for payment...');
setRoutstrOpsStatus(
`Deposit invoice created (${amountSats} sats)` +
`${invoiceId ? `\ninvoice_id: ${invoiceId}` : ''}` +
`${bolt11 ? `\nbolt11: ${bolt11}` : ''}`
);
if (invoiceId) {
pollLightningInvoiceStatus(baseUrl, invoiceId, 'Deposit invoice', 'deposit').catch((e) => {
console.warn('[ai.html] deposit invoice poll failed:', e);
});
}
} catch (error) {
setRoutstrOpsStatus(`Create invoice failed: ${String(error?.message || error)}`);
}
});
}
if (btnRoutstrImportCashu) {
btnRoutstrImportCashu.addEventListener('click', async () => {
const baseUrl = getRoutstrBaseUrl();
const cashuToken = String(inpRoutstrDepositCashu?.value || '').trim();
if (!baseUrl) return setRoutstrOpsStatus('Missing Base URL.');
if (!cashuToken) return setRoutstrOpsStatus('Missing Cashu token to import.');
setRoutstrOpsStatus('Importing Cashu token as API key...');
try {
const endpoint = `${baseUrl}/v1/balance/create?initial_balance_token=${encodeURIComponent(cashuToken)}`;
const response = await fetch(endpoint);
const data = await parseJsonSafe(response);
if (!response.ok) throw buildHttpError('Import Cashu token', endpoint, response, data);
const nextKey = String(data?.api_key || data?.key || '').trim();
if (nextKey) {
inpCfgApiKey.value = nextKey;
aiConfig.api_key = nextKey;
saveAiConfigLocal();
saveAiConfigToUserSettings().catch((e) => console.warn('[ai.html] failed to persist imported key:', e));
}
setRoutstrOpsStatus(`Cashu import complete: ${safeStringify(data)}`);
await getRoutstrBalance();
} catch (error) {
setRoutstrOpsStatus(`Cashu import failed: ${String(error?.message || error)}`);
}
});
}
if (btnRoutstrTopupInvoice) {
btnRoutstrTopupInvoice.addEventListener('click', async () => {
const baseUrl = getRoutstrBaseUrl();
const apiKey = getAuthToken();
const amountSats = Math.max(1, Math.floor(Number(inpRoutstrTopupSats?.value || 0)));
if (!baseUrl) return setRoutstrOpsStatus('Missing Base URL.');
if (!apiKey || !apiKey.startsWith('sk-')) return setRoutstrOpsStatus('Top-up invoice requires an sk- API key in API Key field.');
setRoutstrOpsStatus('Creating top-up invoice...');
try {
const endpoint = `${baseUrl}/v1/balance/lightning/invoice`;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount_sats: amountSats, purpose: 'topup', api_key: apiKey })
});
const data = await parseJsonSafe(response);
if (!response.ok) throw buildHttpError('Create top-up invoice', endpoint, response, data);
const invoiceId = String(data?.invoice_id || '').trim();
const bolt11 = String(data?.bolt11 || '').trim();
updateInvoiceDisplay('topup', {
title: `Top-up invoice created`,
bolt11,
amountSats,
expiresAt: data?.expires_at,
invoiceId
});
setInvoiceStatus('topup', 'Waiting for payment...');
setRoutstrOpsStatus(
`Top-up invoice created (${amountSats} sats)` +
`${invoiceId ? `\ninvoice_id: ${invoiceId}` : ''}` +
`${bolt11 ? `\nbolt11: ${bolt11}` : ''}`
);
if (invoiceId) {
pollLightningInvoiceStatus(baseUrl, invoiceId, 'Top-up invoice', 'topup').catch((e) => {
console.warn('[ai.html] top-up invoice poll failed:', e);
});
}
} catch (error) {
setRoutstrOpsStatus(`Top-up invoice failed: ${String(error?.message || error)}`);
}
});
}
if (btnRoutstrTopupCashu) {
btnRoutstrTopupCashu.addEventListener('click', async () => {
const baseUrl = getRoutstrBaseUrl();
const apiKey = getAuthToken();
const cashuToken = String(inpRoutstrTopupCashu?.value || '').trim();
if (!baseUrl) return setRoutstrOpsStatus('Missing Base URL.');
if (!apiKey || !apiKey.startsWith('sk-')) return setRoutstrOpsStatus('Cashu top-up requires an sk- API key in API Key field.');
if (!cashuToken) return setRoutstrOpsStatus('Missing Cashu token for top-up.');
setRoutstrOpsStatus('Submitting Cashu top-up...');
try {
const endpoint = `${baseUrl}/v1/balance/topup`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({ cashu_token: cashuToken })
});
const data = await parseJsonSafe(response);
if (!response.ok) throw buildHttpError('Cashu top-up', endpoint, response, data);
setRoutstrOpsStatus(`Cashu top-up complete: ${safeStringify(data)}`);
await getRoutstrBalance();
} catch (error) {
setRoutstrOpsStatus(`Cashu top-up failed: ${String(error?.message || error)}`);
}
});
}
if (btnRoutstrRefund) {
btnRoutstrRefund.addEventListener('click', async () => {
const baseUrl = getRoutstrBaseUrl();
const apiKey = getAuthToken();
if (!baseUrl) return setRoutstrOpsStatus('Missing Base URL.');
if (!apiKey || !apiKey.startsWith('sk-')) return setRoutstrOpsStatus('Refund requires an sk- API key in API Key field.');
setRoutstrOpsStatus('Requesting refund token...');
try {
const endpoint = `${baseUrl}/v1/balance/refund`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`
}
});
const data = await parseJsonSafe(response);
if (!response.ok) throw buildHttpError('Refund balance', endpoint, response, data);
const refundToken = String(data?.token || '').trim();
const refundMsats = String(data?.msats || '').trim();
if (taRoutstrRefundToken) taRoutstrRefundToken.value = refundToken;
setRoutstrOpsStatus(`Refund received${refundMsats ? ` (${refundMsats} msats)` : ''}.`);
await getRoutstrBalance();
} catch (error) {
setRoutstrOpsStatus(`Refund failed: ${String(error?.message || error)}`);
}
});
}
btnAiSend.addEventListener('click', () => {
sendPrompt();
});
taAiPrompt.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendPrompt();
}
});
if (btnAiAttach && inpAiImageUpload) {
btnAiAttach.addEventListener('click', () => {
inpAiImageUpload.click();
});
inpAiImageUpload.addEventListener('change', async (event) => {
const files = event?.target?.files;
if (!files || files.length === 0) return;
await appendPendingImageFiles(files);
inpAiImageUpload.value = '';
});
}
if (divAiInputArea) {
const onDragEnter = (event) => {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
aiInputDragDepth += 1;
divAiInputArea.classList.add('dragover');
};
const onDragOver = (event) => {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
divAiInputArea.classList.add('dragover');
};
const onDragLeave = (event) => {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
aiInputDragDepth = Math.max(0, aiInputDragDepth - 1);
if (aiInputDragDepth === 0) divAiInputArea.classList.remove('dragover');
};
const onDrop = async (event) => {
if (!event.dataTransfer?.files?.length) return;
event.preventDefault();
aiInputDragDepth = 0;
divAiInputArea.classList.remove('dragover');
await appendPendingImageFiles(event.dataTransfer.files);
};
divAiInputArea.addEventListener('dragenter', onDragEnter);
divAiInputArea.addEventListener('dragover', onDragOver);
divAiInputArea.addEventListener('dragleave', onDragLeave);
divAiInputArea.addEventListener('drop', onDrop);
}
const onPasteImages = async (event) => {
const clipboardItems = Array.from(event.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
if (imageFiles.length === 0) return;
event.preventDefault();
await appendPendingImageFiles(imageFiles);
};
taAiPrompt.addEventListener('paste', onPasteImages);
if (divAiInputArea) {
divAiInputArea.addEventListener('paste', onPasteImages);
}
selAiModel.addEventListener('change', () => {
selectModel(selAiModel.value || '');
});
taAiSystemPrompt.addEventListener('change', () => {
const convo = getCurrentConversation();
if (!convo) return;
convo.systemPrompt = String(taAiSystemPrompt.value || '').trim() || DEFAULT_TV_SYSTEM_PROMPT;
updateConversation({ systemPrompt: convo.systemPrompt });
const taAiSystemPromptSidebar = document.getElementById('taAiSystemPromptSidebar');
if (taAiSystemPromptSidebar) taAiSystemPromptSidebar.value = convo.systemPrompt;
});
if (btnAiSystemPromptToggle && divAiSystemPromptBody) {
btnAiSystemPromptToggle.addEventListener('click', () => {
const open = divAiSystemPromptBody.classList.toggle('open');
btnAiSystemPromptToggle.textContent = `${open ? '▼' : '▶'} System Prompt`;
});
}
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 || 4096)));
aiConfig.temperature = Number(inpCfgTemperature.value || 0.7);
syncSelectedProviderProfile();
saveAiConfigLocal();
try {
await saveAiConfigToUserSettings();
setStatus('AI endpoint config saved to user-settings.');
} catch (error) {
console.warn('[ai.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 modelUrls = [`${baseUrl}/v1/models`, `${baseUrl}/models`];
let lastError = null;
for (const url of modelUrls) {
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
if (!response.ok) {
lastError = new Error(`Failed to fetch models from ${url} (HTTP ${response.status})`);
continue;
}
const data = await response.json();
fetchedModels = data.data || [];
renderModelDropdown();
return;
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('Failed to fetch models from all candidate endpoints');
} catch (error) {
console.warn('[ai.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('[ai.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);
// Initial fetch
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('[ai.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 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(`[ai.html ${versionInfo.VERSION}] Loading...`);
try {
initHamburgerMenu();
bindUiEvents();
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('[ai.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('[ai.html] failed to apply user-settings update:', error);
}
});
renderAiConfigForm();
applyAiConfigToHeader();
updateProviderSections();
loadConversations();
renderConversationList();
renderCurrentConversation();
renderPendingAttachments();
if (btnAiSystemPromptToggle && divAiSystemPromptBody) {
divAiSystemPromptBody.classList.remove('open');
btnAiSystemPromptToggle.textContent = '▶ System Prompt';
}
bindRelayActivityListeners();
setStatus('Ready.');
getRoutstrBalance().catch((error) => {
console.warn('[ai.html] initial Routstr balance fetch failed:', error);
});
updateIntervalId = setInterval(UpdateFooter, 1000);
await updateVersionDisplay();
} catch (error) {
console.error('[ai.html] initialization failed:', error);
setStatus(`Initialization failed: ${error?.message || error}`);
}
})();
</script>
</body>
</html>