3654 lines
131 KiB
HTML
3654 lines
131 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>AI</title>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
<link rel="stylesheet" href="./css/messaging-ui.css" />
|
|
<link rel="stylesheet" href="./css/dot-menu.css" />
|
|
<link rel="stylesheet" href="./css/post-composer.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="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
|
|
<script src="./js/marked.min.js"></script>
|
|
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
|
|
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/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: 10px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.aiConversationsSection,
|
|
.aiSkillsSection {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.aiSkillsSection {
|
|
border-top: 1px solid var(--muted-color);
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.aiPaneTitle {
|
|
font-size: 90%;
|
|
font-weight: bold;
|
|
color: var(--primary-color);
|
|
border-bottom: 1px solid var(--muted-color);
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
#divAiConversationsList,
|
|
#divSkillsList {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.skillFilterTabs {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.skillFilterTab {
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 6px;
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
font-size: 75%;
|
|
padding: 4px 8px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.skillFilterTab.active {
|
|
border-color: var(--primary-color);
|
|
color: var(--primary-color);
|
|
box-shadow: 0 0 0 1px var(--primary-color) inset;
|
|
}
|
|
|
|
.skillItem {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.skillItem input[type="checkbox"] {
|
|
margin-top: 3px;
|
|
accent-color: var(--accent-color);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.skillItem.dimmed {
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.skillRequiresTool {
|
|
margin-top: 4px;
|
|
font-size: 70%;
|
|
color: var(--muted-color);
|
|
white-space: normal;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.aiConversationTitle {
|
|
font-size: 85%;
|
|
font-weight: bold;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.aiConversationHeader {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 6px;
|
|
}
|
|
|
|
.aiConversationTitleInput {
|
|
width: 100%;
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 6px;
|
|
padding: 2px 6px;
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
font-size: 85%;
|
|
font-weight: bold;
|
|
font-family: var(--font-family);
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.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: 3px;
|
|
font-size: 72%;
|
|
color: var(--muted-color);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
|
|
#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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
#divAiMessages,
|
|
#divAiThreadHost {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
|
|
#divAiStatus {
|
|
font-size: 75%;
|
|
color: var(--muted-color);
|
|
padding: 0 10px 8px 10px;
|
|
min-height: 16px;
|
|
}
|
|
|
|
#detailsSkillArea {
|
|
border-top: 1px solid var(--muted-color);
|
|
border-bottom: 1px solid var(--muted-color);
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
#detailsSkillArea summary {
|
|
cursor: pointer;
|
|
color: var(--primary-color);
|
|
font-size: 78%;
|
|
font-weight: bold;
|
|
user-select: none;
|
|
}
|
|
|
|
#detailsSkillArea[open] summary {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
#divSelectedSkillsEditor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.skillStackedItem {
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 8px;
|
|
padding: 6px;
|
|
}
|
|
|
|
.skillStackedItem summary {
|
|
cursor: pointer;
|
|
font-size: 78%;
|
|
color: var(--primary-color);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.aiSkillControlsRow {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.aiSkillCompactRow {
|
|
display: grid;
|
|
grid-template-columns: minmax(180px, 2fr) repeat(3, minmax(88px, 1fr));
|
|
gap: 8px;
|
|
align-items: end;
|
|
}
|
|
|
|
.aiSkillInlineField {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.aiSkillInlineField label {
|
|
font-size: 72%;
|
|
color: var(--muted-color);
|
|
}
|
|
|
|
.taSkillTemplateEditor {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
min-height: 72px;
|
|
max-height: 320px;
|
|
resize: vertical;
|
|
border: 1px solid var(--muted-color);
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
font-family: var(--font-family);
|
|
font-size: 83%;
|
|
}
|
|
|
|
.aiSkillActionsRow {
|
|
margin-top: 8px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.aiSkillHint {
|
|
font-size: 72%;
|
|
color: var(--muted-color);
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.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</div>
|
|
</div>
|
|
<div id="divHeaderFlexRight"></div>
|
|
</div>
|
|
|
|
<div id="divBody">
|
|
<div id="divAiLayout">
|
|
<div id="divAiConversationsPane">
|
|
<div class="aiConversationsSection">
|
|
<div class="aiPaneTitle">Conversations</div>
|
|
<div id="divAiConversationsList"></div>
|
|
<button id="btnAiNewChat" class="btn" style="width: 100%;">+ New Chat</button>
|
|
</div>
|
|
|
|
<div class="aiSkillsSection">
|
|
<div class="aiPaneTitle">Skills</div>
|
|
<div class="skillFilterTabs">
|
|
<button id="btnSkillFilterAll" class="skillFilterTab" type="button">All</button>
|
|
<button id="btnSkillFilterMy" class="skillFilterTab active" type="button">My</button>
|
|
</div>
|
|
<div id="divSkillsList"></div>
|
|
<div style="display:flex; gap:8px;">
|
|
<button id="btnSkillNew" class="btn" style="flex:1;">+ New Skill</button>
|
|
<button id="btnSkillClearAll" class="btn" style="flex:1;">Clear All</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="divAiChatPane">
|
|
<select id="selAiProvider" class="aiSelect" style="display:none;"></select>
|
|
<select id="selAiModel" class="aiSelect" style="display:none;"></select>
|
|
|
|
<div id="divAiThreadHost"></div>
|
|
|
|
<details id="detailsSkillArea">
|
|
<summary id="summarySkillArea">Skills (0 selected)</summary>
|
|
<div id="divSelectedSkillsEditor"></div>
|
|
</details>
|
|
|
|
<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 id="divAiConfigPanel" class="aiSideSection aiCompactSourcePanel">
|
|
<div class="aiSideLabel">OpenAI-Compatible Config</div>
|
|
<div class="aiConfigField">
|
|
<label for="selCfgProvider">Provider</label>
|
|
<select id="selCfgProvider" class="aiInput"></select>
|
|
</div>
|
|
<div id="divCfgProviderNameGroup" class="aiConfigField" style="display: none;">
|
|
<label for="inpCfgProviderName">New Provider Name</label>
|
|
<input id="inpCfgProviderName" class="aiInput" type="text" placeholder="my-provider" />
|
|
</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: stretch;">
|
|
<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; align-self: stretch;">★</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 id="divAiPaymentsPanel" class="aiSideSection aiCompactSourcePanel">
|
|
<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-lite.js"></script>
|
|
|
|
<script type="module">
|
|
import {
|
|
initNDKPage,
|
|
getPubkey, injectHeaderAvatar,
|
|
disconnect,
|
|
getVersion,
|
|
updateVersionDisplay,
|
|
getUserSettings,
|
|
patchUserSettings,
|
|
onUserSettings,
|
|
subscribe,
|
|
publishEvent,
|
|
ndkFetchEvents,
|
|
queryCache
|
|
} 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 {
|
|
initAiSection,
|
|
updateAiSection,
|
|
getDefaultAiConfig,
|
|
normalizeBaseUrl as uiNormalizeBaseUrl,
|
|
getChatCompletionsUrl as uiGetChatCompletionsUrl,
|
|
normalizeAiConfig as uiNormalizeAiConfig,
|
|
mergeProvidersPreservingSecrets as uiMergeProvidersPreservingSecrets,
|
|
mergeAiConfigFromSettings as uiMergeAiConfigFromSettings,
|
|
loadAiConfigLocal as uiLoadAiConfigLocal,
|
|
saveAiConfigLocal as uiSaveAiConfigLocal,
|
|
getProviderEntries as uiGetProviderEntries
|
|
} from './js/ai-ui.mjs';
|
|
import { mountMessagingWindow } from './js/messaging-ui.mjs';
|
|
const STORAGE_KEY = 'ai_chat_conversations_v1';
|
|
const AI_CONFIG_KEY = 'ai_chat_openai_config_v1';
|
|
const AI_NOSTR_KIND = 30078;
|
|
const AI_NOSTR_TAG = 'client-ai-chat-v1';
|
|
const AI_NOSTR_MIGRATED_KEY = 'ai_chat_nostr_migrated_v1';
|
|
const AVAILABLE_TOOLS = [];
|
|
const DEFAULT_SKILL_TEMPLATE = 'system:\nYou are a helpful assistant.\n\nuser:\n{{message}}';
|
|
|
|
let updateIntervalId = null;
|
|
let currentPubkey = null;
|
|
let hamburgerInstance = null;
|
|
let isNavOpen = false;
|
|
let logoutHamburger = null;
|
|
let themeToggleHamburger = null;
|
|
let isDarkMode = false;
|
|
|
|
let selectedConversationId = null;
|
|
let conversations = [];
|
|
let activeAssistantMessageId = null;
|
|
let isSending = false;
|
|
let aiConfig = getDefaultAiConfig();
|
|
|
|
let fetchedModels = [];
|
|
let fetchModels = async () => {};
|
|
let activeInvoicePollId = 0;
|
|
let rawApiKeyValue = '';
|
|
let rawCreditIdValue = '';
|
|
let isLoadingNostrConversations = false;
|
|
let lastConversationDTagMs = 0;
|
|
let suppressNostrConversationPublish = false;
|
|
let aiThreadUi = null;
|
|
|
|
let skills = [];
|
|
let selectedSkillKeys = [];
|
|
let skillFilterMode = 'my';
|
|
let skillSubscription = null;
|
|
let skillSubId = null;
|
|
let skillEditorValues = {};
|
|
|
|
const divSideNav = document.getElementById('divSideNav');
|
|
const divAiConfigPanel = document.getElementById('divAiConfigPanel');
|
|
const divAiPaymentsPanel = document.getElementById('divAiPaymentsPanel');
|
|
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 divSkillsList = document.getElementById('divSkillsList');
|
|
const divAiThreadHost = document.getElementById('divAiThreadHost');
|
|
const divAiStatus = document.getElementById('divAiStatus');
|
|
const summarySkillArea = document.getElementById('summarySkillArea');
|
|
const detailsSkillArea = document.getElementById('detailsSkillArea');
|
|
const divSelectedSkillsEditor = document.getElementById('divSelectedSkillsEditor');
|
|
|
|
const btnSkillFilterAll = document.getElementById('btnSkillFilterAll');
|
|
const btnSkillFilterMy = document.getElementById('btnSkillFilterMy');
|
|
const btnSkillNew = document.getElementById('btnSkillNew');
|
|
const btnSkillClearAll = document.getElementById('btnSkillClearAll');
|
|
|
|
const selAiProvider = document.getElementById('selAiProvider');
|
|
const selAiModel = document.getElementById('selAiModel');
|
|
const btnAiNewChat = document.getElementById('btnAiNewChat');
|
|
|
|
const selCfgProvider = document.getElementById('selCfgProvider');
|
|
const divCfgProviderNameGroup = document.getElementById('divCfgProviderNameGroup');
|
|
const inpCfgProviderName = document.getElementById('inpCfgProviderName');
|
|
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 nextConversationDTag() {
|
|
const now = Date.now();
|
|
const ms = now > lastConversationDTagMs ? now : lastConversationDTagMs + 1;
|
|
lastConversationDTagMs = ms;
|
|
return new Date(ms).toISOString();
|
|
}
|
|
|
|
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 normalizeSensitiveRaw(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function maskSensitiveValue(value) {
|
|
const raw = normalizeSensitiveRaw(value);
|
|
if (!raw) return '';
|
|
if (raw.length <= 7) return raw;
|
|
return `${raw.slice(0, 4)}${'•'.repeat(raw.length - 7)}${raw.slice(-3)}`;
|
|
}
|
|
|
|
function setApiKeyRawValue(value, { masked = true } = {}) {
|
|
rawApiKeyValue = normalizeSensitiveRaw(value);
|
|
if (!inpCfgApiKey) return;
|
|
inpCfgApiKey.dataset.rawValue = rawApiKeyValue;
|
|
inpCfgApiKey.value = masked ? maskSensitiveValue(rawApiKeyValue) : rawApiKeyValue;
|
|
}
|
|
|
|
function setCreditIdRawValue(value, { masked = true } = {}) {
|
|
rawCreditIdValue = normalizeSensitiveRaw(value);
|
|
if (!inpCfgCreditId) return;
|
|
inpCfgCreditId.dataset.rawValue = rawCreditIdValue;
|
|
inpCfgCreditId.value = masked ? maskSensitiveValue(rawCreditIdValue) : rawCreditIdValue;
|
|
}
|
|
|
|
function getAuthToken() {
|
|
return normalizeSensitiveRaw(rawApiKeyValue || aiConfig.api_key || '');
|
|
}
|
|
|
|
function getCreditIdToken() {
|
|
return normalizeSensitiveRaw(rawCreditIdValue || aiConfig.credit_id || '');
|
|
}
|
|
|
|
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'}`;
|
|
}
|
|
|
|
async function readFileAsDataUrl(file) {
|
|
return await new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(String(reader.result || ''));
|
|
reader.onerror = () => reject(reader.error || new Error('Failed to read image file.'));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function filesToImageAttachments(files) {
|
|
const list = Array.from(files || [])
|
|
.filter((file) => file && file.size > 0 && String(file.type || '').startsWith('image/'));
|
|
if (list.length === 0) return [];
|
|
|
|
const rawAttachments = [];
|
|
for (const file of list) {
|
|
const dataUrl = await readFileAsDataUrl(file);
|
|
rawAttachments.push({
|
|
id: uid(),
|
|
name: String(file?.name || 'image').trim() || 'image',
|
|
mimeType: String(file?.type || '').trim(),
|
|
dataUrl
|
|
});
|
|
}
|
|
|
|
return normalizeMessageAttachments(rawAttachments);
|
|
}
|
|
|
|
|
|
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 getTagValue(tags, key) {
|
|
if (!Array.isArray(tags)) return '';
|
|
const tag = tags.find((t) => Array.isArray(t) && t[0] === key && t.length > 1);
|
|
return String(tag?.[1] || '').trim();
|
|
}
|
|
|
|
function getAllTagValues(tags, key) {
|
|
if (!Array.isArray(tags)) return [];
|
|
return tags
|
|
.filter((t) => Array.isArray(t) && t[0] === key && t.length > 1)
|
|
.map((t) => String(t?.[1] || '').trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function hasTagValue(tags, key, value) {
|
|
const needle = String(value || '').trim();
|
|
if (!needle) return false;
|
|
return getAllTagValues(tags, key).includes(needle);
|
|
}
|
|
|
|
function normalizeSlug(raw) {
|
|
return String(raw || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^a-z0-9._-]/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function skillKey(pubkey, slug) {
|
|
return `${String(pubkey || '').trim()}:${normalizeSlug(slug)}`;
|
|
}
|
|
|
|
function splitSkillTemplate(template) {
|
|
const raw = String(template || '').trim();
|
|
if (!raw) return { system: '', userTemplate: '' };
|
|
const marker = '\nuser:\n';
|
|
const idx = raw.indexOf(marker);
|
|
if (idx === -1) {
|
|
return {
|
|
system: raw.replace(/^system:\n/, '').trim(),
|
|
userTemplate: ''
|
|
};
|
|
}
|
|
return {
|
|
system: raw.substring(0, idx).replace(/^system:\n/, '').trim(),
|
|
userTemplate: raw.substring(idx + marker.length).trim()
|
|
};
|
|
}
|
|
|
|
function parseSkillTemplate(template, userInput) {
|
|
const parts = splitSkillTemplate(template);
|
|
const system = String(parts.system || '').trim();
|
|
const userTemplate = String(parts.userTemplate || '').trim();
|
|
const user = userTemplate
|
|
? userTemplate.replaceAll('{{message}}', String(userInput || '').trim()).trim()
|
|
: String(userInput || '').trim();
|
|
return { system, user, userTemplate };
|
|
}
|
|
|
|
function parseSkillEvent(evt) {
|
|
if (!evt || evt.kind !== 31123) return null;
|
|
const slug = normalizeSlug(getTagValue(evt.tags, 'd'));
|
|
if (!slug) return null;
|
|
|
|
const requiresTools = getAllTagValues(evt.tags, 'requires_tool');
|
|
const optionalTools = getAllTagValues(evt.tags, 'optional_tool');
|
|
const requiresSkills = getAllTagValues(evt.tags, 'requires_skill');
|
|
const unavailableRequiredTools = requiresTools.filter((tool) => !AVAILABLE_TOOLS.includes(tool));
|
|
|
|
const skill = {
|
|
key: skillKey(evt.pubkey, slug),
|
|
slug,
|
|
description: getTagValue(evt.tags, 'description') || slug,
|
|
template: String(evt.content || '').trim(),
|
|
temperature: Number.isFinite(Number(getTagValue(evt.tags, 'temperature'))) ? Number(getTagValue(evt.tags, 'temperature')) : 0,
|
|
max_tokens: Number.isFinite(Number(getTagValue(evt.tags, 'max_tokens'))) ? Math.max(1, Math.floor(Number(getTagValue(evt.tags, 'max_tokens')))) : 10000,
|
|
llm: String(getTagValue(evt.tags, 'llm') || 'default').trim() || 'default',
|
|
seed: Number.isFinite(Number(getTagValue(evt.tags, 'seed'))) ? Math.floor(Number(getTagValue(evt.tags, 'seed'))) : null,
|
|
requires_tools: requiresTools,
|
|
optional_tools: optionalTools,
|
|
requires_skills: requiresSkills,
|
|
unavailable_required_tools: unavailableRequiredTools,
|
|
needsUnavailableTools: unavailableRequiredTools.length > 0,
|
|
author: String(evt.pubkey || ''),
|
|
created_at: Number(evt.created_at || 0),
|
|
raw: evt
|
|
};
|
|
|
|
try {
|
|
const parsed = JSON.parse(String(evt.content || '').trim());
|
|
if (parsed && typeof parsed === 'object') {
|
|
if (String(parsed.template || '').trim()) skill.template = String(parsed.template || '').trim();
|
|
if (String(parsed.description || '').trim()) skill.description = String(parsed.description || '').trim();
|
|
if (Number.isFinite(Number(parsed.temperature))) skill.temperature = Number(parsed.temperature);
|
|
if (Number.isFinite(Number(parsed.max_tokens))) skill.max_tokens = Math.max(1, Math.floor(Number(parsed.max_tokens)));
|
|
if (String(parsed.llm || '').trim()) skill.llm = String(parsed.llm).trim();
|
|
if (Number.isFinite(Number(parsed.seed))) skill.seed = Math.floor(Number(parsed.seed));
|
|
}
|
|
} catch (_error) {
|
|
// Content is expected to be markdown template; keep as-is.
|
|
}
|
|
|
|
return skill;
|
|
}
|
|
|
|
function getSelectedSkills() {
|
|
return selectedSkillKeys
|
|
.map((key) => skills.find((s) => s.key === key) || null)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildSkillEventFromEditor(skill) {
|
|
const edit = getSkillEditorValue(skill);
|
|
return {
|
|
kind: 31123,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['d', edit.slug],
|
|
['description', edit.description || edit.slug],
|
|
['llm', String(edit.llm || 'default').trim() || 'default'],
|
|
['temperature', String(Number(edit.temperature ?? 0))],
|
|
['max_tokens', String(Math.max(1, Math.floor(Number(edit.max_tokens ?? 10000))))]
|
|
],
|
|
content: String(edit.template || DEFAULT_SKILL_TEMPLATE).trim()
|
|
};
|
|
}
|
|
|
|
function nextNewSkillSlug() {
|
|
const base = 'new-skill';
|
|
const mine = new Set(skills.filter((s) => s.author === currentPubkey).map((s) => s.slug));
|
|
if (!mine.has(base)) return base;
|
|
let idx = 2;
|
|
while (mine.has(`${base}-${idx}`)) idx += 1;
|
|
return `${base}-${idx}`;
|
|
}
|
|
|
|
async function createNewSkill() {
|
|
if (!currentPubkey) {
|
|
setStatus('Connect signer first to create skills.');
|
|
return;
|
|
}
|
|
|
|
const slug = nextNewSkillSlug();
|
|
const draftSkill = {
|
|
key: skillKey(currentPubkey, slug),
|
|
slug,
|
|
description: '',
|
|
template: DEFAULT_SKILL_TEMPLATE,
|
|
temperature: Number(aiConfig.temperature ?? 0),
|
|
max_tokens: 10000,
|
|
llm: String(aiConfig.model || 'default').trim() || 'default',
|
|
seed: null,
|
|
requires_tools: [],
|
|
optional_tools: [],
|
|
requires_skills: [],
|
|
unavailable_required_tools: [],
|
|
needsUnavailableTools: false,
|
|
author: currentPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
raw: null
|
|
};
|
|
|
|
skillEditorValues[draftSkill.key] = {
|
|
slug: draftSkill.slug,
|
|
description: draftSkill.description,
|
|
llm: draftSkill.llm,
|
|
temperature: String(draftSkill.temperature),
|
|
max_tokens: String(draftSkill.max_tokens),
|
|
seed: '',
|
|
template: draftSkill.template
|
|
};
|
|
|
|
const skillEvent = buildSkillEventFromEditor(draftSkill);
|
|
try {
|
|
await publishEvent(skillEvent);
|
|
upsertSkillFromEvent({
|
|
...skillEvent,
|
|
pubkey: currentPubkey,
|
|
id: uid()
|
|
});
|
|
if (!selectedSkillKeys.includes(draftSkill.key)) {
|
|
selectedSkillKeys.push(draftSkill.key);
|
|
}
|
|
persistSelectedSkillsToConversation();
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
if (detailsSkillArea) detailsSkillArea.open = true;
|
|
setStatus(`Created skill: ${slug}`);
|
|
} catch (error) {
|
|
console.error('[ai.html] failed to publish new skill:', error);
|
|
setStatus(`Failed to create skill: ${String(error?.message || error)}`);
|
|
}
|
|
}
|
|
|
|
function getSkillEditorValue(skill) {
|
|
const edit = skillEditorValues?.[skill.key] || {};
|
|
return {
|
|
slug: normalizeSlug(String(edit.slug ?? skill.slug ?? '').trim()),
|
|
description: String(edit.description ?? skill.description ?? '').trim(),
|
|
template: String(edit.template ?? skill.template ?? '').trim(),
|
|
temperature: Number.isFinite(Number(edit.temperature)) ? Number(edit.temperature) : Number(skill.temperature ?? 0),
|
|
max_tokens: Number.isFinite(Number(edit.max_tokens)) ? Math.max(1, Math.floor(Number(edit.max_tokens))) : Math.max(1, Math.floor(Number(skill.max_tokens ?? 10000))),
|
|
llm: String(edit.llm ?? skill.llm ?? 'default').trim() || 'default',
|
|
seed: edit.seed === '' || edit.seed === null || edit.seed === undefined
|
|
? (Number.isFinite(Number(skill.seed)) ? Math.floor(Number(skill.seed)) : null)
|
|
: (Number.isFinite(Number(edit.seed)) ? Math.floor(Number(edit.seed)) : null)
|
|
};
|
|
}
|
|
|
|
function getEffectiveSkillParams() {
|
|
const selected = getSelectedSkills();
|
|
if (selected.length === 0) return null;
|
|
const last = selected[selected.length - 1];
|
|
const edit = getSkillEditorValue(last);
|
|
return {
|
|
llm: edit.llm,
|
|
temperature: edit.temperature,
|
|
max_tokens: edit.max_tokens,
|
|
seed: edit.seed
|
|
};
|
|
}
|
|
|
|
function getConversationSkillKeys(convo) {
|
|
return Array.isArray(convo?.skillKeys)
|
|
? convo.skillKeys.map((x) => String(x || '').trim()).filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
function syncSelectedSkillsFromConversation(convo) {
|
|
const target = getConversationSkillKeys(convo);
|
|
const curr = selectedSkillKeys.join('|');
|
|
const next = target.join('|');
|
|
if (curr === next) return;
|
|
selectedSkillKeys = target;
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
}
|
|
|
|
function persistSelectedSkillsToConversation() {
|
|
const convo = getCurrentConversation();
|
|
if (!convo) return;
|
|
convo.skillKeys = [...selectedSkillKeys];
|
|
updateConversation({ skillKeys: [...selectedSkillKeys] });
|
|
}
|
|
|
|
function toggleSkillSelection(key, enabled) {
|
|
const skillKeyRaw = String(key || '').trim();
|
|
if (!skillKeyRaw) return;
|
|
const exists = selectedSkillKeys.includes(skillKeyRaw);
|
|
if (enabled && !exists) {
|
|
selectedSkillKeys.push(skillKeyRaw);
|
|
} else if (!enabled && exists) {
|
|
selectedSkillKeys = selectedSkillKeys.filter((k) => k !== skillKeyRaw);
|
|
}
|
|
persistSelectedSkillsToConversation();
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
}
|
|
|
|
function buildSkillLlmOptionRows(filterValue, currentLlm) {
|
|
const normalizedFilter = String(filterValue || '').trim().toLowerCase();
|
|
const favoriteIds = new Set((aiConfig.favorites || []).map((id) => String(id || '').trim()).filter(Boolean));
|
|
const rows = [];
|
|
const seen = new Set();
|
|
const pushRow = (id) => {
|
|
const modelId = String(id || '').trim();
|
|
if (!modelId || seen.has(modelId)) return;
|
|
if (normalizedFilter && !modelId.toLowerCase().includes(normalizedFilter)) return;
|
|
seen.add(modelId);
|
|
const isFav = favoriteIds.has(modelId);
|
|
rows.push(`<div class="aiDropdownOption ${isFav ? 'favorite' : ''} ${modelId === currentLlm ? 'active' : ''}" data-llm-id="${escapeHtml(modelId)}">${escapeHtml(modelId)}</div>`);
|
|
};
|
|
|
|
pushRow('default');
|
|
if (currentLlm) pushRow(currentLlm);
|
|
const ordered = getOrderedModels(normalizedFilter);
|
|
for (const m of ordered.fav) pushRow(m.id);
|
|
for (const m of ordered.other) pushRow(m.id);
|
|
|
|
return rows.join('') || '<div class="aiDropdownHint">No models match filter.</div>';
|
|
}
|
|
|
|
function bindSkillLlmDropdown(dropdownEl) {
|
|
if (!dropdownEl) return;
|
|
const key = String(dropdownEl.getAttribute('data-skill-key') || '').trim();
|
|
if (!key) return;
|
|
const skill = skills.find((s) => s.key === key);
|
|
if (!skill) return;
|
|
|
|
const toggle = dropdownEl.querySelector('[data-role="llm-toggle"]');
|
|
const filterEl = dropdownEl.querySelector('[data-role="llm-filter"]');
|
|
const optionsEl = dropdownEl.querySelector('[data-role="llm-options"]');
|
|
|
|
const renderOptions = () => {
|
|
const current = String(getSkillEditorValue(skill).llm || 'default').trim() || 'default';
|
|
if (toggle) toggle.textContent = current;
|
|
if (optionsEl) {
|
|
optionsEl.innerHTML = buildSkillLlmOptionRows(filterEl?.value || '', current);
|
|
Array.from(optionsEl.querySelectorAll('.aiDropdownOption[data-llm-id]')).forEach((opt) => {
|
|
opt.addEventListener('click', () => {
|
|
const llm = String(opt.getAttribute('data-llm-id') || '').trim();
|
|
if (!llm) return;
|
|
if (!skillEditorValues[key]) skillEditorValues[key] = {};
|
|
skillEditorValues[key].llm = llm;
|
|
if (toggle) toggle.textContent = llm;
|
|
setDropdownOpen(dropdownEl, false);
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
if (toggle) {
|
|
toggle.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
const willOpen = !dropdownEl.classList.contains('open');
|
|
closeAllCustomDropdowns();
|
|
setDropdownOpen(dropdownEl, willOpen);
|
|
if (willOpen && filterEl) {
|
|
filterEl.value = '';
|
|
renderOptions();
|
|
filterEl.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (filterEl) {
|
|
filterEl.addEventListener('click', (event) => event.stopPropagation());
|
|
filterEl.addEventListener('input', renderOptions);
|
|
}
|
|
|
|
renderOptions();
|
|
}
|
|
|
|
async function saveSkillByKey(key) {
|
|
const skillKeyRaw = String(key || '').trim();
|
|
if (!skillKeyRaw) return;
|
|
const skill = skills.find((s) => s.key === skillKeyRaw);
|
|
if (!skill) {
|
|
setStatus('Skill not found.');
|
|
return;
|
|
}
|
|
if (!currentPubkey || skill.author !== currentPubkey) {
|
|
setStatus('You can only update your own skills.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const edit = getSkillEditorValue(skill);
|
|
const oldKey = skill.key;
|
|
const oldSlug = skill.slug;
|
|
const newSlug = edit.slug;
|
|
const newKey = skillKey(currentPubkey, newSlug);
|
|
|
|
const eventToPublish = buildSkillEventFromEditor(skill);
|
|
await publishEvent(eventToPublish);
|
|
|
|
if (newKey !== oldKey) {
|
|
skills = skills.filter((s) => s.key !== oldKey);
|
|
delete skillEditorValues[oldKey];
|
|
selectedSkillKeys = selectedSkillKeys.map((k) => (k === oldKey ? newKey : k));
|
|
persistSelectedSkillsToConversation();
|
|
}
|
|
|
|
upsertSkillFromEvent({
|
|
...eventToPublish,
|
|
pubkey: currentPubkey,
|
|
id: uid()
|
|
});
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
setStatus(`Updated skill: ${oldSlug} → ${newSlug}`);
|
|
} catch (error) {
|
|
console.error('[ai.html] failed to update skill:', error);
|
|
setStatus(`Failed to update skill: ${String(error?.message || error)}`);
|
|
}
|
|
}
|
|
|
|
async function publishSkillDeleteToNostr(skill) {
|
|
const slug = normalizeSlug(skill?.slug || '');
|
|
const author = String(skill?.author || '').trim();
|
|
if (!slug || !author) return;
|
|
|
|
const deleteEvent = {
|
|
kind: 5,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['a', `31123:${author}:${slug}`],
|
|
['k', '31123']
|
|
],
|
|
content: ''
|
|
};
|
|
|
|
await publishEvent(deleteEvent);
|
|
}
|
|
|
|
function deleteSkillByKey(key) {
|
|
const skillKeyRaw = String(key || '').trim();
|
|
if (!skillKeyRaw) return;
|
|
const skill = skills.find((s) => s.key === skillKeyRaw);
|
|
if (!skill) {
|
|
setStatus('Skill not found.');
|
|
return;
|
|
}
|
|
if (!currentPubkey || skill.author !== currentPubkey) {
|
|
setStatus('You can only delete your own skills.');
|
|
return;
|
|
}
|
|
if (!confirm(`Delete skill "${skill.slug}"?`)) return;
|
|
|
|
skills = skills.filter((s) => s.key !== skillKeyRaw);
|
|
delete skillEditorValues[skillKeyRaw];
|
|
selectedSkillKeys = selectedSkillKeys.filter((k) => k !== skillKeyRaw);
|
|
persistSelectedSkillsToConversation();
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
setStatus(`Deleted skill: ${skill.slug}`);
|
|
|
|
publishSkillDeleteToNostr(skill).catch((error) => {
|
|
console.warn('[ai.html] failed to publish skill delete tombstone:', error);
|
|
});
|
|
}
|
|
|
|
function renderSkillsEditor() {
|
|
const selected = getSelectedSkills();
|
|
const names = selected.map((s) => s.slug);
|
|
if (summarySkillArea) {
|
|
summarySkillArea.textContent = names.length > 0
|
|
? `Skills (${names.length} selected)`
|
|
: 'Skills (0 selected)';
|
|
}
|
|
if (!divSelectedSkillsEditor) return;
|
|
|
|
if (selected.length === 0) {
|
|
divSelectedSkillsEditor.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
divSelectedSkillsEditor.innerHTML = selected.map((skill) => {
|
|
const edit = getSkillEditorValue(skill);
|
|
const canSave = Boolean(currentPubkey && skill.author === currentPubkey);
|
|
return `
|
|
<details class="skillStackedItem" data-skill-key="${escapeHtml(skill.key)}">
|
|
<summary>${escapeHtml(skill.slug)}</summary>
|
|
<div class="aiSkillControlsRow">
|
|
<div class="aiSkillInlineField">
|
|
<label>Name</label>
|
|
<input class="aiInput" data-skill-key="${escapeHtml(skill.key)}" data-field="slug" value="${escapeHtml(edit.slug)}" />
|
|
</div>
|
|
<div class="aiSkillInlineField">
|
|
<label>Description</label>
|
|
<input class="aiInput" data-skill-key="${escapeHtml(skill.key)}" data-field="description" value="${escapeHtml(edit.description)}" />
|
|
</div>
|
|
<div class="aiSkillCompactRow">
|
|
<div class="aiSkillInlineField">
|
|
<label>LLM</label>
|
|
<div class="aiDropdown aiSkillLlmDropdown" data-skill-key="${escapeHtml(skill.key)}">
|
|
<button class="aiDropdownBtn" type="button" data-role="llm-toggle">${escapeHtml(edit.llm || 'default')}</button>
|
|
<div class="aiDropdownPanel">
|
|
<input class="aiInput aiDropdownFilter" data-role="llm-filter" placeholder="filter models..." />
|
|
<div class="aiDropdownOptions" data-role="llm-options"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="aiSkillInlineField">
|
|
<label>Temperature</label>
|
|
<input class="aiInput" type="number" step="0.1" data-skill-key="${escapeHtml(skill.key)}" data-field="temperature" value="${escapeHtml(String(edit.temperature ?? 0))}" />
|
|
</div>
|
|
<div class="aiSkillInlineField">
|
|
<label>Max Tokens</label>
|
|
<input class="aiInput" type="number" min="1" step="1" data-skill-key="${escapeHtml(skill.key)}" data-field="max_tokens" value="${escapeHtml(String(edit.max_tokens ?? 10000))}" />
|
|
</div>
|
|
<div class="aiSkillInlineField">
|
|
<label>Seed</label>
|
|
<input class="aiInput" type="number" step="1" data-skill-key="${escapeHtml(skill.key)}" data-field="seed" value="${escapeHtml(edit.seed === null || edit.seed === undefined ? '' : String(edit.seed))}" />
|
|
</div>
|
|
</div>
|
|
<div class="aiSkillInlineField">
|
|
<label>Template</label>
|
|
<textarea class="taSkillTemplateEditor" data-skill-key="${escapeHtml(skill.key)}" data-field="template">${escapeHtml(edit.template)}</textarea>
|
|
</div>
|
|
</div>
|
|
<div class="aiSkillActionsRow">
|
|
<button class="btn" data-action="save-skill" data-skill-key="${escapeHtml(skill.key)}" ${canSave ? '' : 'disabled'}>${canSave ? 'Save / Update Skill' : 'View Only (not your skill)'}</button>
|
|
</div>
|
|
</details>
|
|
`;
|
|
}).join('');
|
|
|
|
Array.from(divSelectedSkillsEditor.querySelectorAll('[data-skill-key][data-field]')).forEach((el) => {
|
|
el.addEventListener('input', () => {
|
|
const key = String(el.getAttribute('data-skill-key') || '').trim();
|
|
const field = String(el.getAttribute('data-field') || '').trim();
|
|
if (!key || !field) return;
|
|
if (!skillEditorValues[key]) skillEditorValues[key] = {};
|
|
skillEditorValues[key][field] = el.value;
|
|
});
|
|
});
|
|
|
|
Array.from(divSelectedSkillsEditor.querySelectorAll('.aiSkillLlmDropdown')).forEach((dropdownEl) => {
|
|
bindSkillLlmDropdown(dropdownEl);
|
|
});
|
|
|
|
Array.from(divSelectedSkillsEditor.querySelectorAll('button[data-action="save-skill"][data-skill-key]')).forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const key = String(btn.getAttribute('data-skill-key') || '').trim();
|
|
saveSkillByKey(key);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderSkillsList() {
|
|
if (!divSkillsList) return;
|
|
const scoped = skills
|
|
.filter((skill) => (skillFilterMode === 'my' ? Boolean(currentPubkey && skill.author === currentPubkey) : true))
|
|
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
|
|
if (btnSkillFilterAll) btnSkillFilterAll.classList.toggle('active', skillFilterMode === 'all');
|
|
if (btnSkillFilterMy) btnSkillFilterMy.classList.toggle('active', skillFilterMode === 'my');
|
|
|
|
if (scoped.length === 0) {
|
|
divSkillsList.innerHTML = '<div class="aiConversationPreview">No skills found.</div>';
|
|
return;
|
|
}
|
|
|
|
divSkillsList.innerHTML = scoped.map((skill) => {
|
|
const checked = selectedSkillKeys.includes(skill.key) ? 'checked' : '';
|
|
const dimmed = skill.needsUnavailableTools ? 'dimmed' : '';
|
|
const canDelete = Boolean(currentPubkey && skill.author === currentPubkey);
|
|
const required = skill.unavailable_required_tools.length > 0
|
|
? `<div class="skillRequiresTool">Requires tools: ${escapeHtml(skill.unavailable_required_tools.join(', '))}</div>`
|
|
: '';
|
|
return `
|
|
<label class="aiConversationItem skillItem ${dimmed}" title="${escapeHtml(skill.needsUnavailableTools ? `Requires unavailable tools: ${skill.unavailable_required_tools.join(', ')}` : skill.description || skill.slug)}">
|
|
<input class="skillCheckbox" type="checkbox" data-skill-key="${escapeHtml(skill.key)}" ${checked} />
|
|
<div style="min-width:0; flex:1;">
|
|
<div class="aiConversationHeader">
|
|
<div class="aiConversationTitle">${escapeHtml(skill.slug)}</div>
|
|
${canDelete ? `<button class="aiDeleteConvBtn aiDeleteSkillBtn" type="button" title="Delete Skill" aria-label="Delete Skill" data-skill-key="${escapeHtml(skill.key)}"><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(skill.description || '')}</div>
|
|
${required}
|
|
</div>
|
|
</label>
|
|
`;
|
|
}).join('');
|
|
|
|
Array.from(divSkillsList.querySelectorAll('input.skillCheckbox[data-skill-key]')).forEach((el) => {
|
|
el.addEventListener('change', () => {
|
|
const key = String(el.getAttribute('data-skill-key') || '').trim();
|
|
toggleSkillSelection(key, Boolean(el.checked));
|
|
});
|
|
});
|
|
|
|
Array.from(divSkillsList.querySelectorAll('button.aiDeleteSkillBtn[data-skill-key]')).forEach((btn) => {
|
|
btn.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const key = String(btn.getAttribute('data-skill-key') || '').trim();
|
|
deleteSkillByKey(key);
|
|
});
|
|
});
|
|
}
|
|
|
|
function upsertSkillFromEvent(evt) {
|
|
const parsed = parseSkillEvent(evt);
|
|
if (!parsed) return;
|
|
const idx = skills.findIndex((s) => s.key === parsed.key);
|
|
if (idx >= 0) {
|
|
const prevTs = Number(skills[idx]?.created_at || 0);
|
|
const nextTs = Number(parsed.created_at || 0);
|
|
if (nextTs >= prevTs) skills[idx] = parsed;
|
|
} else {
|
|
skills.push(parsed);
|
|
}
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
}
|
|
|
|
function clearSkillSubscription() {
|
|
if (skillSubscription && typeof skillSubscription.close === 'function') {
|
|
skillSubscription.close();
|
|
}
|
|
skillSubscription = null;
|
|
skillSubId = null;
|
|
}
|
|
|
|
function refreshSkills() {
|
|
clearSkillSubscription();
|
|
skills = [];
|
|
renderSkillsList();
|
|
setStatus('Loading skills...');
|
|
|
|
const filter = {
|
|
kinds: [31123],
|
|
limit: 200
|
|
};
|
|
if (skillFilterMode === 'my' && currentPubkey) {
|
|
filter.authors = [currentPubkey];
|
|
}
|
|
|
|
skillSubscription = subscribe(filter, {
|
|
closeOnEose: false,
|
|
cacheUsage: 'CACHE_FIRST'
|
|
});
|
|
skillSubId = skillSubscription?.subId || null;
|
|
}
|
|
|
|
function bindSkillEventListeners() {
|
|
window.addEventListener('ndkEvent', (event) => {
|
|
const evt = event?.detail;
|
|
if (!evt || evt.kind !== 31123) return;
|
|
upsertSkillFromEvent(evt);
|
|
});
|
|
|
|
window.addEventListener('ndkEose', (event) => {
|
|
const eoseSubId = event?.detail?.subId || null;
|
|
if (skillSubId && eoseSubId && eoseSubId !== skillSubId) return;
|
|
if (!divAiStatus.textContent || divAiStatus.textContent === 'Loading skills...') {
|
|
setStatus(skills.length > 0 ? 'Ready.' : 'No skills found.');
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildMultiSkillPrompt(userInput) {
|
|
const selected = getSelectedSkills();
|
|
if (selected.length === 0) {
|
|
return {
|
|
systemPrompt: '',
|
|
resolvedUser: String(userInput || '').trim(),
|
|
overrides: null
|
|
};
|
|
}
|
|
|
|
let resolvedUser = String(userInput || '').trim();
|
|
let userTemplateSeen = false;
|
|
const systemParts = [];
|
|
|
|
for (const skill of selected) {
|
|
const edit = getSkillEditorValue(skill);
|
|
const parsed = parseSkillTemplate(edit.template, userInput);
|
|
if (parsed.system) systemParts.push(parsed.system);
|
|
if (parsed.userTemplate) {
|
|
resolvedUser = parsed.user;
|
|
userTemplateSeen = true;
|
|
}
|
|
}
|
|
|
|
if (!userTemplateSeen) {
|
|
resolvedUser = String(userInput || '').trim();
|
|
}
|
|
|
|
return {
|
|
systemPrompt: systemParts.join('\n\n---\n\n').trim(),
|
|
resolvedUser,
|
|
overrides: getEffectiveSkillParams()
|
|
};
|
|
}
|
|
|
|
function toConversationPayload(convo, { deleted = false } = {}) {
|
|
const safeMessages = Array.isArray(convo?.messages)
|
|
? convo.messages.map((msg) => ({
|
|
id: String(msg?.id || uid()),
|
|
role: String(msg?.role || 'user'),
|
|
content: String(msg?.content || ''),
|
|
sats: Number(msg?.sats || 0),
|
|
createdAt: Number(msg?.createdAt || nowTs())
|
|
}))
|
|
: [];
|
|
|
|
return {
|
|
v: 1,
|
|
conversation_id: String(convo?.id || ''),
|
|
title: String(convo?.title || 'New Chat'),
|
|
updated_at_ms: Number(convo?.updatedAt || nowTs()),
|
|
model_id: String(convo?.modelId || ''),
|
|
provider_name: String(aiConfig?.provider || ''),
|
|
skill_keys: Array.isArray(convo?.skillKeys) ? convo.skillKeys : [],
|
|
messages: deleted ? [] : safeMessages,
|
|
deleted: Boolean(deleted)
|
|
};
|
|
}
|
|
|
|
async function nip44EncryptToSelf(plaintext) {
|
|
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
|
|
if (!selfPubkey) throw new Error('Missing pubkey for NIP-44 encryption');
|
|
if (!window.nostr?.nip44?.encrypt) throw new Error('NIP-44 encryption not supported by signer');
|
|
return window.nostr.nip44.encrypt(selfPubkey, plaintext);
|
|
}
|
|
|
|
async function nip44DecryptFromSelf(ciphertext) {
|
|
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
|
|
if (!selfPubkey) throw new Error('Missing pubkey for NIP-44 decryption');
|
|
if (!window.nostr?.nip44?.decrypt) throw new Error('NIP-44 decryption not supported by signer');
|
|
return window.nostr.nip44.decrypt(selfPubkey, ciphertext);
|
|
}
|
|
|
|
async function publishConversationToNostr(convo, { deleted = false } = {}) {
|
|
if (!convo?.id) return;
|
|
const payload = toConversationPayload(convo, { deleted });
|
|
const plaintext = JSON.stringify(payload);
|
|
const ciphertext = await nip44EncryptToSelf(plaintext);
|
|
const event = {
|
|
kind: AI_NOSTR_KIND,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['d', String(convo.id)],
|
|
['t', AI_NOSTR_TAG]
|
|
],
|
|
content: ciphertext
|
|
};
|
|
console.log('[ai.html] publishing encrypted conversation event', event);
|
|
await publishEvent(event);
|
|
}
|
|
|
|
async function publishConversationDeleteToNostr(conversationId) {
|
|
const id = String(conversationId || '').trim();
|
|
if (!id) return;
|
|
const existing = conversations.find((c) => String(c?.id || '') === id) || {
|
|
id,
|
|
title: 'Deleted',
|
|
modelId: '',
|
|
messages: [],
|
|
updatedAt: nowTs()
|
|
};
|
|
await publishConversationToNostr(existing, { deleted: true });
|
|
}
|
|
|
|
async function mapConversationEvents(events) {
|
|
const byDTag = new Map();
|
|
for (const evt of (Array.isArray(events) ? events : [])) {
|
|
const d = getTagValue(evt?.tags, 'd');
|
|
if (!d) continue;
|
|
const prev = byDTag.get(d);
|
|
if (!prev || Number(evt?.created_at || 0) > Number(prev?.created_at || 0)) {
|
|
byDTag.set(d, evt);
|
|
}
|
|
}
|
|
|
|
const out = [];
|
|
for (const evt of byDTag.values()) {
|
|
const d = getTagValue(evt?.tags, 'd');
|
|
if (!d) continue;
|
|
try {
|
|
const plaintext = await nip44DecryptFromSelf(String(evt?.content || ''));
|
|
const parsed = JSON.parse(String(plaintext || '{}'));
|
|
if (parsed?.deleted) continue;
|
|
const messages = Array.isArray(parsed?.messages)
|
|
? parsed.messages.map((msg) => ({
|
|
id: String(msg?.id || uid()),
|
|
role: String(msg?.role || 'user'),
|
|
content: String(msg?.content || ''),
|
|
typing: false,
|
|
sats: Number(msg?.sats || 0),
|
|
attachments: normalizeMessageAttachments(msg?.attachments),
|
|
createdAt: Number(msg?.createdAt || nowTs())
|
|
}))
|
|
: [];
|
|
out.push({
|
|
id: d,
|
|
title: String(parsed?.title || 'New Chat'),
|
|
modelId: String(parsed?.model_id || ''),
|
|
skillKeys: Array.isArray(parsed?.skill_keys)
|
|
? parsed.skill_keys.map((x) => String(x || '').trim()).filter(Boolean)
|
|
: [],
|
|
messages,
|
|
createdAt: Number(Date.parse(d) || nowTs()),
|
|
updatedAt: Number(parsed?.updated_at_ms || nowTs())
|
|
});
|
|
} catch (_error) {
|
|
// ignore malformed or undecryptable conversation events
|
|
}
|
|
}
|
|
|
|
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
return out;
|
|
}
|
|
|
|
async function hydrateConversationsFromNostr() {
|
|
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
|
|
if (!selfPubkey) return [];
|
|
|
|
const filter = {
|
|
kinds: [AI_NOSTR_KIND],
|
|
authors: [selfPubkey],
|
|
'#t': [AI_NOSTR_TAG],
|
|
limit: 500
|
|
};
|
|
|
|
const cachedEvents = await queryCache(filter).catch(() => []);
|
|
const fromCache = await mapConversationEvents(cachedEvents);
|
|
if (fromCache.length > 0) {
|
|
void ndkFetchEvents(filter).then(async (relayEvents) => {
|
|
const fromRelays = await mapConversationEvents(relayEvents);
|
|
if (fromRelays.length > 0) {
|
|
conversations = fromRelays;
|
|
selectedConversationId = conversations[0]?.id || null;
|
|
saveConversations();
|
|
renderConversationList();
|
|
renderCurrentConversation();
|
|
}
|
|
}).catch(() => {});
|
|
return fromCache;
|
|
}
|
|
|
|
const allEvents = await ndkFetchEvents(filter);
|
|
return await mapConversationEvents(allEvents);
|
|
}
|
|
|
|
async function migrateLocalConversationsToNostr(localConversations) {
|
|
if (localStorage.getItem(AI_NOSTR_MIGRATED_KEY) === 'true') return;
|
|
const list = Array.isArray(localConversations) ? localConversations : [];
|
|
if (list.length === 0) {
|
|
localStorage.setItem(AI_NOSTR_MIGRATED_KEY, 'true');
|
|
return;
|
|
}
|
|
|
|
for (const convo of list) {
|
|
try {
|
|
await publishConversationToNostr(convo, { deleted: false });
|
|
} catch (error) {
|
|
console.warn('[ai.html] local->nostr migration publish failed:', error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
localStorage.setItem(AI_NOSTR_MIGRATED_KEY, 'true');
|
|
}
|
|
|
|
|
|
async function loadConversations() {
|
|
let loadedLocal = [];
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
loadedLocal = Array.isArray(JSON.parse(raw || '[]')) ? JSON.parse(raw || '[]') : [];
|
|
} catch (_error) {
|
|
loadedLocal = [];
|
|
}
|
|
|
|
const normalizedLocal = loadedLocal
|
|
.map((conv) => ({
|
|
id: String(conv?.id || uid()),
|
|
title: String(conv?.title || 'New Chat'),
|
|
modelId: String(conv?.modelId || ''),
|
|
skillKeys: Array.isArray(conv?.skillKeys)
|
|
? conv.skillKeys.map((x) => String(x || '').trim()).filter(Boolean)
|
|
: (Array.isArray(conv?.skill_keys) ? conv.skill_keys.map((x) => String(x || '').trim()).filter(Boolean) : []),
|
|
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);
|
|
|
|
isLoadingNostrConversations = true;
|
|
try {
|
|
const fromNostr = await hydrateConversationsFromNostr();
|
|
if (fromNostr.length > 0) {
|
|
conversations = fromNostr;
|
|
selectedConversationId = conversations[0].id;
|
|
saveConversations();
|
|
localStorage.setItem(AI_NOSTR_MIGRATED_KEY, 'true');
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.warn('[ai.html] failed to load encrypted conversations from nostr:', error);
|
|
} finally {
|
|
isLoadingNostrConversations = false;
|
|
}
|
|
|
|
conversations = normalizedLocal;
|
|
if (conversations.length === 0) {
|
|
createConversation();
|
|
return;
|
|
}
|
|
|
|
selectedConversationId = conversations[0].id;
|
|
migrateLocalConversationsToNostr(conversations).catch((error) => {
|
|
console.warn('[ai.html] migration to nostr failed:', error);
|
|
});
|
|
}
|
|
|
|
function createConversation() {
|
|
const convo = {
|
|
id: nextConversationDTag(),
|
|
title: 'New Chat',
|
|
modelId: '',
|
|
skillKeys: [],
|
|
messages: [],
|
|
createdAt: nowTs(),
|
|
updatedAt: nowTs()
|
|
};
|
|
conversations.unshift(convo);
|
|
selectedConversationId = convo.id;
|
|
saveConversations();
|
|
renderConversationList();
|
|
renderCurrentConversation();
|
|
if (!isLoadingNostrConversations) {
|
|
publishConversationToNostr(convo).catch((error) => {
|
|
console.warn('[ai.html] failed to publish new conversation:', error);
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
if (!isLoadingNostrConversations && !suppressNostrConversationPublish) {
|
|
publishConversationToNostr(next).catch((error) => {
|
|
console.warn('[ai.html] failed to publish conversation update:', error);
|
|
});
|
|
}
|
|
}
|
|
|
|
function forcePublishConversationSnapshot(convo, reason = '') {
|
|
if (!convo || isLoadingNostrConversations) return;
|
|
publishConversationToNostr(convo).catch((error) => {
|
|
console.warn(`[ai.html] failed to publish immediate snapshot${reason ? ` (${reason})` : ''}:`, error);
|
|
});
|
|
}
|
|
|
|
function ensureConversationTitle(convo) {
|
|
if (!convo) return;
|
|
if (convo.title && convo.title !== 'New Chat') 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 Chat';
|
|
}
|
|
|
|
function renderConversationList() {
|
|
const rows = conversations
|
|
.map((conv) => {
|
|
const active = conv.id === selectedConversationId ? 'active' : '';
|
|
const previewMsg = conv.messages[conv.messages.length - 1];
|
|
const preview = String(previewMsg?.content || 'No messages yet').replace(/\s+/g, ' ').slice(0, 70);
|
|
return `
|
|
<div class="aiConversationItem ${active}" data-conversation-id="${escapeHtml(conv.id)}">
|
|
<div style="flex-grow: 1; overflow: hidden;">
|
|
<div class="aiConversationHeader">
|
|
<div class="aiConversationTitle" title="Double-click to rename">${escapeHtml(conv.title || 'New Chat')}</div>
|
|
<input class="aiConversationTitleInput" type="text" value="${escapeHtml(conv.title || 'New Chat')}" style="display:none;" />
|
|
<button class="aiDeleteConvBtn" title="Delete Conversation" aria-label="Delete Conversation">
|
|
<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(preview)}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
})
|
|
.join('');
|
|
|
|
divAiConversationsList.innerHTML = rows || '<div class="aiConversationPreview">No conversations</div>';
|
|
|
|
Array.from(divAiConversationsList.querySelectorAll('.aiConversationItem')).forEach((el) => {
|
|
el.addEventListener('click', () => {
|
|
const nextId = String(el.getAttribute('data-conversation-id') || '');
|
|
if (!nextId) return;
|
|
if (selectedConversationId === nextId) {
|
|
renderCurrentConversation();
|
|
return;
|
|
}
|
|
selectedConversationId = nextId;
|
|
renderConversationList();
|
|
renderCurrentConversation();
|
|
});
|
|
|
|
const titleEl = el.querySelector('.aiConversationTitle');
|
|
const titleInput = el.querySelector('.aiConversationTitleInput');
|
|
titleEl.addEventListener('dblclick', (e) => {
|
|
e.stopPropagation();
|
|
titleEl.style.display = 'none';
|
|
titleInput.style.display = 'block';
|
|
titleInput.focus();
|
|
titleInput.select();
|
|
});
|
|
|
|
const commitTitle = () => {
|
|
const id = el.getAttribute('data-conversation-id');
|
|
const index = conversations.findIndex(c => c.id === id);
|
|
if (index < 0) return;
|
|
conversations[index].title = String(titleInput.value || '').trim() || 'New Chat';
|
|
conversations[index].updatedAt = nowTs();
|
|
saveConversations();
|
|
renderConversationList();
|
|
if (id === selectedConversationId) renderCurrentConversation();
|
|
if (!isLoadingNostrConversations) {
|
|
publishConversationToNostr(conversations[index]).catch((error) => {
|
|
console.warn('[ai.html] failed to publish renamed conversation:', error);
|
|
});
|
|
}
|
|
};
|
|
|
|
titleInput.addEventListener('click', (e) => e.stopPropagation());
|
|
titleInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
commitTitle();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
renderConversationList();
|
|
}
|
|
});
|
|
titleInput.addEventListener('blur', commitTitle);
|
|
|
|
const delBtn = el.querySelector('.aiDeleteConvBtn');
|
|
delBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = el.getAttribute('data-conversation-id');
|
|
deleteConversation(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
function deleteConversation(id) {
|
|
if (!confirm('Delete this conversation?')) return;
|
|
|
|
const deletedId = String(id || '').trim();
|
|
conversations = conversations.filter(c => c.id !== deletedId);
|
|
if (selectedConversationId === deletedId) {
|
|
selectedConversationId = conversations.length > 0 ? conversations[0].id : null;
|
|
}
|
|
|
|
publishConversationDeleteToNostr(deletedId).catch((error) => {
|
|
console.warn('[ai.html] failed to publish conversation delete tombstone:', error);
|
|
});
|
|
|
|
if (conversations.length === 0) {
|
|
createConversation();
|
|
} else {
|
|
saveConversations();
|
|
renderConversationList();
|
|
renderCurrentConversation();
|
|
}
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
const raw = String(text || '');
|
|
const html = markedLib ? markedLib.parse(raw) : escapeHtml(raw).replaceAll('\n', '<br/>');
|
|
return window.DOMPurify ? window.DOMPurify.sanitize(html) : html;
|
|
}
|
|
|
|
function formatMessageTime(ts) {
|
|
const d = new Date(Number(ts || nowTs()));
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function renderCurrentConversation() {
|
|
const convo = getCurrentConversation();
|
|
if (!convo) {
|
|
aiThreadUi?.setHeader({ name: 'AI Chat' });
|
|
aiThreadUi?.setMessages([]);
|
|
selectedSkillKeys = [];
|
|
renderSkillsEditor();
|
|
return;
|
|
}
|
|
|
|
syncSelectedSkillsFromConversation(convo);
|
|
if (convo.modelId) selAiModel.value = convo.modelId;
|
|
|
|
aiThreadUi?.setHeader({
|
|
name: String(convo.title || 'New Chat')
|
|
});
|
|
|
|
const threadMessages = convo.messages.map((msg) => {
|
|
const role = msg.role === 'user' ? 'user' : msg.role === 'assistant' ? 'assistant' : 'system';
|
|
const metaBits = [formatMessageTime(msg.createdAt)];
|
|
if (msg.sats && Number(msg.sats) > 0) metaBits.push(`${msg.sats} sats`);
|
|
|
|
return {
|
|
id: String(msg.id || uid()),
|
|
role,
|
|
outgoing: role === 'user',
|
|
created_at: Math.floor(Number(msg.createdAt || nowTs()) / 1000),
|
|
content: msg.typing ? '__AI_TYPING__' : String(msg.content || ''),
|
|
protocol: metaBits.join(' · '),
|
|
attachments: normalizeMessageAttachments(msg?.attachments).map((att) => ({
|
|
name: getAttachmentPreviewName(att),
|
|
dataUrl: att.dataUrl
|
|
}))
|
|
};
|
|
});
|
|
|
|
aiThreadUi?.setMessages(threadMessages);
|
|
}
|
|
|
|
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 });
|
|
forcePublishConversationSnapshot(convo, `addMessage:${role}`);
|
|
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 });
|
|
forcePublishConversationSnapshot(convo, 'patchMessage');
|
|
renderCurrentConversation();
|
|
}
|
|
|
|
function conversationToApiMessages(convo, { currentPrompt = '' } = {}) {
|
|
const out = [];
|
|
const promptText = String(currentPrompt || '').trim();
|
|
const selected = getSelectedSkills();
|
|
const hasSkills = selected.length > 0;
|
|
const skillContext = buildMultiSkillPrompt(promptText);
|
|
const sys = String(skillContext.systemPrompt || '').trim();
|
|
if (sys) {
|
|
out.push({ role: 'system', content: sys });
|
|
}
|
|
|
|
const lastPromptUserIndex = hasSkills && promptText
|
|
? convo.messages.map((msg, index) => ({ msg, index }))
|
|
.filter(({ msg }) => msg?.role === 'user')
|
|
.reverse()
|
|
.find(({ msg }) => String(msg?.content || '').trim() === promptText)?.index
|
|
: -1;
|
|
|
|
for (let i = 0; i < convo.messages.length; i += 1) {
|
|
const msg = convo.messages[i];
|
|
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
|
|
|
if (hasSkills && promptText && i === lastPromptUserIndex && msg.role === 'user') {
|
|
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 });
|
|
}
|
|
|
|
if (hasSkills && promptText) {
|
|
out.push({ role: 'user', content: String(skillContext.resolvedUser || promptText) });
|
|
}
|
|
|
|
return {
|
|
messages: out,
|
|
overrides: hasSkills ? skillContext.overrides : null
|
|
};
|
|
}
|
|
|
|
function normalizeBaseUrl(baseUrlRaw) {
|
|
return uiNormalizeBaseUrl(baseUrlRaw);
|
|
}
|
|
|
|
function getChatCompletionsUrl(baseUrlRaw) {
|
|
return uiGetChatCompletionsUrl(baseUrlRaw);
|
|
}
|
|
|
|
function loadAiConfig() {
|
|
aiConfig = uiLoadAiConfigLocal(aiConfig, AI_CONFIG_KEY);
|
|
}
|
|
|
|
function normalizeAiConfig(input = {}) {
|
|
return uiNormalizeAiConfig(input, aiConfig.providers || []);
|
|
}
|
|
|
|
function mergeProvidersPreservingSecrets(existingProviders = [], incomingProviders = []) {
|
|
return uiMergeProvidersPreservingSecrets(existingProviders, incomingProviders);
|
|
}
|
|
|
|
function mergeAiConfigFromSettings(currentConfig, settingsAiRaw = {}) {
|
|
return uiMergeAiConfigFromSettings(currentConfig, settingsAiRaw);
|
|
}
|
|
|
|
function saveAiConfigLocal() {
|
|
aiConfig = uiSaveAiConfigLocal(aiConfig, AI_CONFIG_KEY);
|
|
}
|
|
|
|
async function saveAiConfigToUserSettings() {
|
|
syncSelectedProviderProfile();
|
|
const normalized = normalizeAiConfig(aiConfig);
|
|
aiConfig = {
|
|
...aiConfig,
|
|
...normalized
|
|
};
|
|
await patchUserSettings({ global_llm: normalized });
|
|
}
|
|
|
|
function getProviderEntries() {
|
|
return uiGetProviderEntries(aiConfig);
|
|
}
|
|
|
|
function setDropdownOpen(dropdownEl, open) {
|
|
if (!dropdownEl) return;
|
|
dropdownEl.classList.toggle('open', Boolean(open));
|
|
}
|
|
|
|
function closeAllCustomDropdowns() {
|
|
Array.from(document.querySelectorAll('.aiDropdown.open')).forEach((dropdownEl) => {
|
|
setDropdownOpen(dropdownEl, 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;
|
|
setApiKeyRawValue(aiConfig.api_key, { masked: true });
|
|
setCreditIdRawValue(aiConfig.credit_id, { masked: true });
|
|
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();
|
|
|
|
const customSelected = String(aiConfig.provider || '').trim() === 'custom' ? ' selected' : '';
|
|
selCfgProvider.innerHTML =
|
|
providers.map(p => `<option value="${escapeHtml(p.name)}" ${p.name === aiConfig.provider ? 'selected' : ''}>${escapeHtml(p.name)}</option>`).join('') +
|
|
`<option value="custom"${customSelected}>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;
|
|
updateAiSection();
|
|
}
|
|
|
|
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 || '');
|
|
setApiKeyRawValue(aiConfig.api_key, { masked: true });
|
|
setCreditIdRawValue(aiConfig.credit_id, { masked: true });
|
|
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 updateProviderSections() {
|
|
const provider = String(selCfgProvider?.value || aiConfig.provider || '').trim().toLowerCase();
|
|
const isPpq = provider === 'ppq.ai';
|
|
const isRoutstr = provider === 'routstr';
|
|
const isCustom = provider === 'custom';
|
|
if (divPpqCreditIdGroup) divPpqCreditIdGroup.style.display = isPpq ? '' : 'none';
|
|
if (divRoutstrOnlySections) divRoutstrOnlySections.style.display = isRoutstr ? '' : 'none';
|
|
if (divCfgProviderNameGroup) divCfgProviderNameGroup.style.display = isCustom ? '' : 'none';
|
|
}
|
|
|
|
function setRoutstrOpsStatus(text) {
|
|
if (divRoutstrOpsStatus) divRoutstrOpsStatus.textContent = String(text || '');
|
|
}
|
|
|
|
function setRoutstrBalanceState(text) {
|
|
if (divRoutstrBalanceState) divRoutstrBalanceState.textContent = String(text || '');
|
|
}
|
|
|
|
let footerProviderBalanceText = '--';
|
|
|
|
function setHeaderProviderBalance(text) {
|
|
footerProviderBalanceText = String(text || '--');
|
|
if (divFooterCenter) divFooterCenter.textContent = footerProviderBalanceText;
|
|
}
|
|
|
|
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-')) {
|
|
setApiKeyRawValue(apiKey, { masked: true });
|
|
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 = getCreditIdToken();
|
|
if (!creditId) {
|
|
setRoutstrBalanceState('Missing Credit ID for ppq.ai.');
|
|
setHeaderProviderBalance('--');
|
|
return;
|
|
}
|
|
|
|
const endpoint = `${baseUrl}/credits/balance`;
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ credit_id: creditId })
|
|
});
|
|
const data = await parseJsonSafe(response);
|
|
|
|
if (!response.ok) {
|
|
const details = toErrorDetails(data) || 'No response body';
|
|
setRoutstrBalanceState(
|
|
`Balance check failed\n` +
|
|
`HTTP ${response.status} ${response.statusText}\n` +
|
|
`endpoint: ${endpoint}\n` +
|
|
`details: ${details}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const balance = Number(data?.balance);
|
|
const lines = [
|
|
`Balance: ${Number.isFinite(balance) ? balance : '--'} credits`,
|
|
`credit_id: ${creditId}`,
|
|
`Raw: ${safeStringify(data)}`
|
|
];
|
|
setRoutstrBalanceState(lines.join('\n'));
|
|
setHeaderProviderBalance(Number.isFinite(balance) ? `$${balance.toFixed(2)}` : '$--');
|
|
return;
|
|
}
|
|
|
|
if (!apiKey) {
|
|
setRoutstrBalanceState('Missing API Key / Cashu token.');
|
|
setHeaderProviderBalance('--');
|
|
return;
|
|
}
|
|
|
|
const endpoint = `${baseUrl}/v1/balance/info`;
|
|
const requestHeaders = {
|
|
Authorization: `Bearer ${apiKey}`
|
|
};
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'GET',
|
|
headers: requestHeaders
|
|
});
|
|
const data = await parseJsonSafe(response);
|
|
|
|
if (!response.ok) {
|
|
const details = toErrorDetails(data) || 'No response body';
|
|
setRoutstrBalanceState(
|
|
`Balance check failed\n` +
|
|
`HTTP ${response.status} ${response.statusText}\n` +
|
|
`endpoint: ${endpoint}\n` +
|
|
`details: ${details}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const balance = Number(data?.balance);
|
|
const reserved = Number(data?.reserved);
|
|
const currency = String(data?.currency || 'sat').trim();
|
|
const lines = [
|
|
`Balance: ${Number.isFinite(balance) ? balance : '--'} ${currency}`
|
|
];
|
|
if (Number.isFinite(reserved)) lines.push(`Reserved: ${reserved} ${currency}`);
|
|
lines.push(`Raw: ${safeStringify(data)}`);
|
|
|
|
setRoutstrBalanceState(lines.join('\n'));
|
|
setHeaderProviderBalance(Number.isFinite(balance) ? `${Math.floor(balance)} sats` : '-- sats');
|
|
} catch (error) {
|
|
setRoutstrBalanceState(`Balance check failed: ${String(error?.message || error)}`);
|
|
setHeaderProviderBalance('--');
|
|
}
|
|
}
|
|
|
|
async function callOpenAICompatibleChat(messages, overrides = null) {
|
|
const endpoint = getChatCompletionsUrl(aiConfig.base_url || '');
|
|
const apiKey = String(aiConfig.api_key || '').trim();
|
|
const modelOverride = String(overrides?.llm || '').trim();
|
|
const model = modelOverride && modelOverride !== 'default'
|
|
? modelOverride
|
|
: String(aiConfig.model || '').trim();
|
|
const maxTokens = Math.max(1, Math.floor(Number(overrides?.max_tokens ?? aiConfig.max_tokens ?? 4096)));
|
|
const temperature = Number(overrides?.temperature ?? aiConfig.temperature ?? 0.7);
|
|
const seed = Number.isFinite(Number(overrides?.seed)) ? Math.floor(Number(overrides.seed)) : null;
|
|
|
|
if (!endpoint) throw new Error('Missing base URL in sidebar config');
|
|
if (!apiKey) throw new Error('Missing API key in sidebar config');
|
|
if (!model) throw new Error('Missing model in sidebar config');
|
|
|
|
const requestHeaders = {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`
|
|
};
|
|
const requestBody = {
|
|
model,
|
|
messages,
|
|
max_tokens: maxTokens,
|
|
temperature,
|
|
stream: false
|
|
};
|
|
if (Number.isFinite(seed)) requestBody.seed = seed;
|
|
|
|
console.log('[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);
|
|
aiThreadUi?.setDisabled(isSending);
|
|
}
|
|
|
|
async function sendPrompt({ promptOverride = '', attachmentsOverride = [] } = {}) {
|
|
if (isSending) return false;
|
|
const prompt = String(promptOverride || '').trim();
|
|
const attachments = normalizeMessageAttachments(attachmentsOverride);
|
|
if (!prompt && attachments.length === 0) return false;
|
|
|
|
const convo = getCurrentConversation();
|
|
if (!convo) return;
|
|
|
|
suppressNostrConversationPublish = true;
|
|
|
|
const effectiveSkillParams = getEffectiveSkillParams();
|
|
const modelOverride = String(effectiveSkillParams?.llm || '').trim();
|
|
convo.modelId = modelOverride && modelOverride !== 'default'
|
|
? modelOverride
|
|
: String(aiConfig.model || '').trim();
|
|
convo.skillKeys = [...selectedSkillKeys];
|
|
updateConversation({
|
|
modelId: convo.modelId,
|
|
skillKeys: [...selectedSkillKeys]
|
|
});
|
|
|
|
addMessage('user', prompt, { attachments });
|
|
|
|
const assistant = addMessage('assistant', '', { typing: true });
|
|
activeAssistantMessageId = assistant?.id || null;
|
|
|
|
setSendingState(true);
|
|
setStatus('Sending request...');
|
|
|
|
try {
|
|
const apiPayload = conversationToApiMessages(convo, { currentPrompt: prompt });
|
|
const result = await callOpenAICompatibleChat(apiPayload.messages, apiPayload.overrides);
|
|
|
|
if (activeAssistantMessageId) {
|
|
patchMessage(activeAssistantMessageId, {
|
|
content: String(result?.content || ''),
|
|
typing: false,
|
|
sats: 0
|
|
});
|
|
}
|
|
|
|
setStatus(`Done 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 {
|
|
suppressNostrConversationPublish = false;
|
|
const latestConvo = getCurrentConversation();
|
|
if (latestConvo && !isLoadingNostrConversations) {
|
|
publishConversationToNostr(latestConvo).catch((error) => {
|
|
console.warn('[ai.html] failed to publish request/response snapshot:', error);
|
|
});
|
|
}
|
|
activeAssistantMessageId = null;
|
|
setSendingState(false);
|
|
getRoutstrBalance().catch((error) => {
|
|
console.warn('[ai.html] post-message balance refresh failed:', error);
|
|
});
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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();
|
|
if (divFooterCenter) divFooterCenter.textContent = footerProviderBalanceText;
|
|
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 (btnSkillFilterAll) {
|
|
btnSkillFilterAll.addEventListener('click', () => {
|
|
skillFilterMode = 'all';
|
|
renderSkillsList();
|
|
refreshSkills();
|
|
});
|
|
}
|
|
|
|
if (btnSkillFilterMy) {
|
|
btnSkillFilterMy.addEventListener('click', () => {
|
|
skillFilterMode = 'my';
|
|
renderSkillsList();
|
|
refreshSkills();
|
|
});
|
|
}
|
|
|
|
if (btnSkillNew) {
|
|
btnSkillNew.addEventListener('click', () => {
|
|
createNewSkill();
|
|
});
|
|
}
|
|
|
|
if (btnSkillClearAll) {
|
|
btnSkillClearAll.addEventListener('click', () => {
|
|
selectedSkillKeys = [];
|
|
persistSelectedSkillsToConversation();
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
});
|
|
}
|
|
|
|
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) {
|
|
setApiKeyRawValue(nextKey, { masked: true });
|
|
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)}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
if (selAiModel) {
|
|
selAiModel.addEventListener('change', () => {
|
|
selectModel(selAiModel.value || '');
|
|
});
|
|
}
|
|
|
|
btnCfgSave.addEventListener('click', async () => {
|
|
const selectedProvider = String(selCfgProvider.value || '').trim();
|
|
if (selectedProvider === 'custom') {
|
|
const customName = String(inpCfgProviderName?.value || '').trim();
|
|
if (!customName) {
|
|
setStatus('Enter a new provider name before saving Custom provider config.');
|
|
if (inpCfgProviderName) inpCfgProviderName.focus();
|
|
return;
|
|
}
|
|
aiConfig.provider = customName;
|
|
} else {
|
|
aiConfig.provider = selectedProvider;
|
|
}
|
|
|
|
aiConfig.base_url = String(inpCfgBaseUrl.value || '').trim();
|
|
aiConfig.api_key = getAuthToken();
|
|
aiConfig.credit_id = getCreditIdToken();
|
|
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();
|
|
updateProviderSections();
|
|
});
|
|
|
|
fetchModels = async () => {
|
|
const baseUrl = normalizeBaseUrl(inpCfgBaseUrl.value || aiConfig.base_url);
|
|
const apiKey = getAuthToken();
|
|
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 = getAuthToken();
|
|
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';
|
|
if (inpCfgProviderName) inpCfgProviderName.value = '';
|
|
saveAiConfigLocal();
|
|
renderProviderDropdown();
|
|
updateProviderSections();
|
|
return;
|
|
}
|
|
if (inpCfgProviderName) inpCfgProviderName.value = '';
|
|
selectProvider(val);
|
|
});
|
|
|
|
if (selAiProvider) {
|
|
selAiProvider.addEventListener('change', () => {
|
|
const val = selAiProvider.value;
|
|
selectProvider(val);
|
|
});
|
|
}
|
|
|
|
inpCfgBaseUrl.addEventListener('blur', fetchModels);
|
|
if (inpCfgApiKey) {
|
|
inpCfgApiKey.addEventListener('focus', () => {
|
|
setApiKeyRawValue(getAuthToken(), { masked: false });
|
|
});
|
|
inpCfgApiKey.addEventListener('input', () => {
|
|
setApiKeyRawValue(inpCfgApiKey.value, { masked: false });
|
|
});
|
|
inpCfgApiKey.addEventListener('blur', () => {
|
|
setApiKeyRawValue(inpCfgApiKey.value, { masked: true });
|
|
aiConfig.api_key = getAuthToken();
|
|
fetchModels();
|
|
});
|
|
}
|
|
if (inpCfgCreditId) {
|
|
inpCfgCreditId.addEventListener('focus', () => {
|
|
setCreditIdRawValue(getCreditIdToken(), { masked: false });
|
|
});
|
|
inpCfgCreditId.addEventListener('input', () => {
|
|
setCreditIdRawValue(inpCfgCreditId.value, { masked: false });
|
|
});
|
|
inpCfgCreditId.addEventListener('blur', () => {
|
|
setCreditIdRawValue(inpCfgCreditId.value, { masked: true });
|
|
aiConfig.credit_id = getCreditIdToken();
|
|
});
|
|
}
|
|
|
|
// 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();
|
|
bindSkillEventListeners();
|
|
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
await injectHeaderAvatar(currentPubkey);
|
|
|
|
initFooterRelayStatus();
|
|
initSidenavRelaySection();
|
|
await initBlossomSection();
|
|
initAiSection({
|
|
listId: 'divAiProvidersList',
|
|
getProviders: () => getProviderEntries(),
|
|
getActiveProviderName: () => String(aiConfig.provider || '').trim(),
|
|
onSelectProvider: (providerName) => selectProvider(providerName),
|
|
configPanelId: 'divAiConfigPanel',
|
|
paymentsPanelId: 'divAiPaymentsPanel'
|
|
});
|
|
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();
|
|
updateAiSection();
|
|
} catch (error) {
|
|
console.warn('[ai.html] failed to apply user-settings update:', error);
|
|
}
|
|
});
|
|
|
|
renderAiConfigForm();
|
|
applyAiConfigToHeader();
|
|
updateProviderSections();
|
|
updateAiSection();
|
|
|
|
aiThreadUi = mountMessagingWindow(divAiThreadHost, {
|
|
headerName: 'AI Chat',
|
|
emptyStateText: 'Open or start a conversation',
|
|
renderMessageContent: (raw) => {
|
|
if (String(raw || '') === '__AI_TYPING__') {
|
|
return '<span class="msg-typing"><span class="msg-typing-dot"></span><span class="msg-typing-dot"></span><span class="msg-typing-dot"></span></span>';
|
|
}
|
|
return renderMarkdown(raw || '');
|
|
},
|
|
composerOptions: {
|
|
disabled: false,
|
|
showUploadIcon: true,
|
|
uploadMode: 'direct-image',
|
|
uploadIcon: 'image',
|
|
fileAccept: 'image/*',
|
|
showPreview: false,
|
|
submitOnEnter: false,
|
|
alwaysShowSendButton: true,
|
|
onFileAttach: async (files) => {
|
|
try {
|
|
const attachments = await filesToImageAttachments(files);
|
|
if (attachments.length === 0) {
|
|
setStatus('Only image files can be attached in AI chat.');
|
|
}
|
|
return attachments;
|
|
} catch (error) {
|
|
const message = String(error?.message || error || 'Failed to attach image.');
|
|
setStatus(message);
|
|
throw error;
|
|
}
|
|
}
|
|
},
|
|
onSubmit: async (text, payload = {}) => await sendPrompt({
|
|
promptOverride: text,
|
|
attachmentsOverride: payload?.attachments || []
|
|
})
|
|
});
|
|
|
|
await loadConversations();
|
|
renderConversationList();
|
|
renderCurrentConversation();
|
|
renderSkillsList();
|
|
renderSkillsEditor();
|
|
refreshSkills();
|
|
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>
|