2898 lines
102 KiB
HTML
2898 lines
102 KiB
HTML
<!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>
|