2082 lines
71 KiB
HTML
2082 lines
71 KiB
HTML
<!DOCTYPE html>
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<html lang="en" dir="ltr">
|
||
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Skills Edit</title>
|
||
|
||
<link rel="stylesheet" href="./css/client.css" />
|
||
|
||
<script>
|
||
(function () {
|
||
const savedTheme = localStorage.getItem('theme');
|
||
if (savedTheme === 'dark') {
|
||
document.documentElement.classList.add('dark-mode');
|
||
if (document.body) {
|
||
document.body.classList.add('dark-mode');
|
||
}
|
||
}
|
||
})();
|
||
</script>
|
||
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
||
<script src="./js/vendor/svg.min.js"></script>
|
||
|
||
<style>
|
||
#divBody {
|
||
display: flex;
|
||
flex-direction: row !important;
|
||
gap: 10px;
|
||
padding: 10px;
|
||
overflow: hidden;
|
||
align-items: stretch !important;
|
||
justify-content: stretch !important;
|
||
align-content: stretch !important;
|
||
flex-wrap: nowrap !important;
|
||
}
|
||
|
||
.skillsCol {
|
||
border: 2px solid var(--primary-color);
|
||
border-radius: 10px;
|
||
background: var(--secondary-color);
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#divSkillsLeft {
|
||
width: clamp(260px, 23%, 380px);
|
||
min-width: 250px;
|
||
padding: 10px;
|
||
gap: 8px;
|
||
}
|
||
|
||
#divSkillsCenter {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 10px;
|
||
gap: 8px;
|
||
}
|
||
|
||
#divSkillsRight {
|
||
width: clamp(240px, 20%, 340px);
|
||
min-width: 220px;
|
||
padding: 10px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.panelTitle {
|
||
font-size: 95%;
|
||
font-weight: bold;
|
||
color: var(--primary-color);
|
||
border-bottom: 1px solid var(--muted-color);
|
||
padding-bottom: 6px;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.inp,
|
||
.sel,
|
||
.ta {
|
||
border: 1px solid var(--muted-color);
|
||
border-radius: 6px;
|
||
background: var(--secondary-color);
|
||
color: var(--primary-color);
|
||
font-family: var(--font-family);
|
||
font-size: 82%;
|
||
padding: 7px;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.ta {
|
||
resize: vertical;
|
||
min-height: 84px;
|
||
}
|
||
|
||
.btnRow {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.requiresSkillRow {
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.requiresSkillRow #inpRequiresSkill {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
.requiresSkillRow #btnAddRequiresSkill:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.editorActionRow {
|
||
align-items: center;
|
||
}
|
||
|
||
.btn {
|
||
padding: 6px 10px;
|
||
font-size: 78%;
|
||
}
|
||
|
||
#divSkillsTabs {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 6px;
|
||
}
|
||
|
||
.tabBtn.active {
|
||
border-color: var(--primary-color);
|
||
color: var(--primary-color);
|
||
box-shadow: 0 0 0 1px var(--primary-color) inset;
|
||
}
|
||
|
||
#divSkillsList {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding-right: 2px;
|
||
}
|
||
|
||
.skillItem {
|
||
border: 1px solid var(--muted-color);
|
||
border-radius: 8px;
|
||
padding: 7px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.skillItem:hover {
|
||
border-color: var(--accent-color);
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
.skillItem.active {
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 1px var(--primary-color) inset;
|
||
}
|
||
|
||
.skillItemTitle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 6px;
|
||
font-size: 82%;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.skillItemMeta {
|
||
font-size: 70%;
|
||
color: var(--primary-color);
|
||
line-height: 1.3;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.chipStar {
|
||
color: var(--accent-color);
|
||
font-size: 90%;
|
||
line-height: 1;
|
||
}
|
||
|
||
.adoptPanel {
|
||
border-top: 1px solid var(--muted-color);
|
||
padding-top: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-height: 160px;
|
||
}
|
||
|
||
#divAdoptedList {
|
||
max-height: 220px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.adoptItem {
|
||
border: 1px solid var(--muted-color);
|
||
border-radius: 7px;
|
||
padding: 5px 6px;
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 6px;
|
||
align-items: center;
|
||
font-size: 74%;
|
||
}
|
||
|
||
.adoptActions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.miniBtn {
|
||
border: 1px solid var(--muted-color);
|
||
border-radius: 5px;
|
||
background: var(--secondary-color);
|
||
color: var(--primary-color);
|
||
cursor: pointer;
|
||
font-size: 68%;
|
||
line-height: 1;
|
||
padding: 4px 5px;
|
||
min-width: 20px;
|
||
}
|
||
|
||
.miniBtn:hover {
|
||
border-color: var(--accent-color);
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
.grid2 {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.grid3 {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.field label {
|
||
font-size: 70%;
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.tagBox {
|
||
border: 1px solid var(--muted-color);
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.chip {
|
||
border: 1px solid var(--muted-color);
|
||
border-radius: 999px;
|
||
padding: 3px 8px;
|
||
font-size: 68%;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: color-mix(in srgb, var(--secondary-color) 88%, var(--muted-color) 12%);
|
||
}
|
||
|
||
.chip button {
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
color: inherit;
|
||
font-size: 95%;
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
#divEditorWrap {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
border: 1px solid var(--primary-color);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: var(--secondary-color);
|
||
}
|
||
|
||
#preSkillHighlight,
|
||
#taSkillContent {
|
||
margin: 0;
|
||
padding: 10px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
tab-size: 2;
|
||
box-sizing: border-box;
|
||
height: 100%;
|
||
width: 100%;
|
||
overflow: auto;
|
||
}
|
||
|
||
#preSkillHighlight {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
color: var(--primary-color);
|
||
background: transparent;
|
||
}
|
||
|
||
#taSkillContent {
|
||
position: relative;
|
||
border: none;
|
||
resize: none;
|
||
background: transparent;
|
||
color: transparent;
|
||
caret-color: var(--primary-color);
|
||
z-index: 1;
|
||
}
|
||
|
||
.hl-var {
|
||
color: #ffd166;
|
||
}
|
||
|
||
.hl-tool {
|
||
color: #7ae582;
|
||
font-weight: bold;
|
||
}
|
||
|
||
#divEditorStatus {
|
||
min-height: 18px;
|
||
font-size: 74%;
|
||
color: var(--primary-color);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
#divToolsList {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding-right: 2px;
|
||
}
|
||
|
||
.toolItem {
|
||
border: 1px solid var(--muted-color);
|
||
border-radius: 8px;
|
||
padding: 7px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
user-select: none;
|
||
}
|
||
|
||
.toolItem:hover {
|
||
border-color: var(--accent-color);
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
.toolItem.required {
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 1px var(--primary-color) inset;
|
||
}
|
||
|
||
.toolName {
|
||
font-size: 78%;
|
||
font-weight: bold;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.toolDesc {
|
||
font-size: 68%;
|
||
color: var(--primary-color);
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.hint {
|
||
font-size: 68%;
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.triggerFilterHelper {
|
||
border: 1px dashed var(--muted-color);
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
background: var(--secondary-color);
|
||
min-height: 42px;
|
||
}
|
||
|
||
.triggerHelpTitle {
|
||
font-size: 70%;
|
||
font-weight: bold;
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.triggerHelpRow {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
|
||
.triggerHelpRow .inp,
|
||
.triggerHelpRow .sel {
|
||
flex: 1 1 120px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.triggerMini {
|
||
font-size: 66%;
|
||
color: var(--primary-color);
|
||
line-height: 1.25;
|
||
}
|
||
|
||
#divLlmHelper .triggerHelpRow .sel {
|
||
min-width: 160px;
|
||
}
|
||
|
||
@media (max-width: 1280px) {
|
||
#divBody {
|
||
flex-direction: column !important;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
#divSkillsLeft,
|
||
#divSkillsRight {
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
#divSkillsCenter {
|
||
min-height: 500px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div id="divSvgHam" class="divHeaderButtons"></div>
|
||
|
||
<div id="divHeader">
|
||
<div id="divHeaderFlexLeft"></div>
|
||
<div id="divHeaderFlexCenter">
|
||
<div class="divHeaderText">Skills Edit</div>
|
||
</div>
|
||
<div id="divHeaderFlexRight"></div>
|
||
</div>
|
||
|
||
<div id="divBody">
|
||
<div id="divSkillsLeft" class="skillsCol">
|
||
<div class="panelTitle">Skills</div>
|
||
<input id="inpSkillSearch" class="inp" type="text" placeholder="search slug/description/author..." />
|
||
<div id="divSkillsTabs">
|
||
<button id="btnTabMy" class="btn tabBtn active" type="button">My</button>
|
||
<button id="btnTabPublic" class="btn tabBtn" type="button">Public</button>
|
||
<button id="btnTabAdopted" class="btn tabBtn" type="button">Adopted</button>
|
||
</div>
|
||
<div class="btnRow">
|
||
<button id="btnRefreshSkills" class="btn" type="button">Refresh</button>
|
||
<button id="btnNewSkill" class="btn" type="button">New</button>
|
||
</div>
|
||
<div id="divSkillsList"></div>
|
||
|
||
<div class="adoptPanel">
|
||
<div class="panelTitle" style="font-size:82%;">Adopted Order (10123)</div>
|
||
<div id="divAdoptedList"></div>
|
||
<div class="btnRow">
|
||
<button id="btnPublishAdoption" class="btn" type="button">Publish 10123</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divSkillsCenter" class="skillsCol">
|
||
<div class="panelTitle">Editor</div>
|
||
|
||
<div class="grid3">
|
||
<div class="field">
|
||
<label for="selSkillKind">Kind</label>
|
||
<select id="selSkillKind" class="sel">
|
||
<option value="31123">31123 (Public)</option>
|
||
<option value="31124">31124 (Private)</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="inpSkillSlug">Slug (d tag)</label>
|
||
<input id="inpSkillSlug" class="inp" type="text" placeholder="my-skill" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="inpSkillDescription">Description</label>
|
||
<input id="inpSkillDescription" class="inp" type="text" placeholder="Skill description" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>Triggers</label>
|
||
<div id="divTriggersList" style="display:flex; flex-direction:column; gap:6px;"></div>
|
||
<button id="btnAddTrigger" class="btn" type="button">+ Add Trigger</button>
|
||
</div>
|
||
|
||
<div id="divLlmHelper" class="triggerFilterHelper">
|
||
<div class="triggerHelpTitle">LLM helper</div>
|
||
<div class="triggerHelpRow">
|
||
<select id="selLlmProvider" class="sel"></select>
|
||
<input id="inpLlmModelFilter" class="inp" type="text" placeholder="filter models..." />
|
||
<select id="selLlmModel" class="sel"></select>
|
||
</div>
|
||
<div class="triggerHelpRow">
|
||
<button id="btnLlmFavorite" class="btn" type="button">☆ Favorite</button>
|
||
<button id="btnLlmApply" class="btn" type="button">Apply provider/model</button>
|
||
</div>
|
||
<div class="triggerMini" id="divLlmHelperStatus">Loads providers/models from sidebar AI config.</div>
|
||
</div>
|
||
|
||
<div class="grid3">
|
||
<div class="field">
|
||
<label for="inpSkillLlm">LLM</label>
|
||
<input id="inpSkillLlm" class="inp" type="text" placeholder="default" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="inpSkillTemperature">Temperature</label>
|
||
<input id="inpSkillTemperature" class="inp" type="number" min="0" max="2" step="0.1" placeholder="0" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="inpSkillMaxTokens">Max Tokens</label>
|
||
<input id="inpSkillMaxTokens" class="inp" type="number" min="1" step="1" placeholder="4000" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid2">
|
||
<div class="field">
|
||
<label for="inpSkillSeed">Seed</label>
|
||
<input id="inpSkillSeed" class="inp" type="number" step="1" placeholder="optional" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="inpRequiresSkill">Add requires_skill</label>
|
||
<div class="btnRow requiresSkillRow">
|
||
<input id="inpRequiresSkill" class="inp" type="text" placeholder="identity" />
|
||
<button id="btnAddRequiresSkill" class="btn" type="button" disabled>Add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divEditorWrap">
|
||
<pre id="preSkillHighlight" aria-hidden="true"></pre>
|
||
<textarea id="taSkillContent" spellcheck="false" placeholder="## Purpose\nYou are...\n\nInput:\n{{message}}"></textarea>
|
||
</div>
|
||
|
||
<div class="btnRow editorActionRow">
|
||
<button id="btnSaveSkill" class="btn" type="button">Save</button>
|
||
<button id="btnCopyFork" class="btn" type="button">Copy to My Skills</button>
|
||
<button id="btnDeleteSkill" class="btn" type="button">Delete</button>
|
||
<button id="btnAdoptCurrent" class="btn" type="button">Adopt</button>
|
||
<button id="btnRemoveAdoptCurrent" class="btn" type="button">Remove Adopted</button>
|
||
</div>
|
||
|
||
<div id="divEditorStatus">Ready.</div>
|
||
</div>
|
||
|
||
<div id="divSkillsRight" class="skillsCol">
|
||
<div class="panelTitle">Didactyl Tools</div>
|
||
<input id="inpToolSearch" class="inp" type="text" placeholder="filter tools..." />
|
||
<div class="hint">Double-click a tool to add <code>requires_tool</code> and insert <code>{{tool_name}}</code>.</div>
|
||
<div id="divToolsList"></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="divFiles"></div>
|
||
</div>
|
||
|
||
<div id="divAiSection" class="sidenavSection">
|
||
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
||
<div id="divAiList" class="sidenavSectionList">
|
||
<div id="divAiProvidersList">No saved providers yet.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divRelaySection">
|
||
<div id="divRelaySectionTitle">リレー</div>
|
||
<div id="divRelayList">Loading relays...</div>
|
||
</div>
|
||
|
||
<div id="divBlossomSection">
|
||
<div id="divBlossomSectionTitle">ブロッサム</div>
|
||
<div id="divBlossomList">Loading blossom servers...</div>
|
||
</div>
|
||
|
||
<div id="divVersionBar">
|
||
<span id="versionDisplay">v0.0.1</span>
|
||
<div id="divVersionBarButtons">
|
||
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
||
<div id="themeToggleHamburgerContainer"></div>
|
||
</button>
|
||
<button id="logoutButton" title="Logout">
|
||
<div id="logoutHamburgerContainer"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="./nostr.bundle.js"></script>
|
||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||
|
||
<script type="module">
|
||
import {
|
||
initNDKPage,
|
||
getPubkey,
|
||
injectHeaderAvatar,
|
||
subscribe,
|
||
publishEvent,
|
||
disconnect,
|
||
getVersion,
|
||
updateVersionDisplay,
|
||
getUserSettings,
|
||
patchUserSettings,
|
||
onUserSettings,
|
||
ensureMuteListLoaded,
|
||
isEventMuted
|
||
} from './js/init-ndk.mjs';
|
||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||
import {
|
||
initAiSectionWithLocalConfig,
|
||
getDefaultAiConfig,
|
||
loadAiConfigLocal,
|
||
saveAiConfigLocal,
|
||
getProviderEntries,
|
||
mergeAiConfigFromSettings,
|
||
normalizeAiConfig,
|
||
normalizeBaseUrl
|
||
} from './js/ai-ui.mjs';
|
||
|
||
const DIDACTYL_TOOLS = [
|
||
['nostr_post', 'Publish a Nostr event to connected relays'],
|
||
['nostr_delete', 'Request deletion of one or more previously published events'],
|
||
['nostr_react', 'React to a Nostr event with like/dislike/emoji'],
|
||
['nostr_query', 'Query events from relays using a Nostr filter'],
|
||
['nostr_my_events', 'Query recent events authored by this agent'],
|
||
['nostr_dm_send', 'Send a NIP-04 encrypted DM'],
|
||
['nostr_dm_send_nip17', 'Send a private DM using NIP-17 gift wrap protocol'],
|
||
['nostr_profile_get', 'Look up a Nostr profile by pubkey'],
|
||
['nostr_nip05_lookup', 'Look up or verify a NIP-05 identifier'],
|
||
['nostr_encode', 'Encode a Nostr entity into nostr: URI'],
|
||
['nostr_decode', 'Decode a Nostr bech32/nostr: URI'],
|
||
['nostr_relay_status', 'Get connection status and statistics for all relays'],
|
||
['nostr_relay_info', 'Fetch NIP-11 relay information document'],
|
||
['nostr_subscription_status', 'List currently managed runtime Nostr subscriptions'],
|
||
['nostr_subscription_set', 'Update one managed runtime subscription by name'],
|
||
['nostr_encrypt', 'Encrypt plaintext using NIP-44 for a recipient'],
|
||
['nostr_decrypt', 'Decrypt NIP-44 ciphertext from a sender'],
|
||
['nostr_list_manage', 'Add/remove tag tuples in replaceable list events'],
|
||
['nostr_block_list', 'View blocked pubkeys, event IDs, or hashtags'],
|
||
['nostr_block_edit', 'Add or remove blocked tuples in kind-10000 block list'],
|
||
['nostr_pubkey', 'Return this agent pubkey in hex format'],
|
||
['nostr_npub', 'Return this agent pubkey encoded as npub'],
|
||
['my_pubkey', 'Alias for nostr_pubkey'],
|
||
['my_npub', 'Alias for nostr_npub'],
|
||
['agent_identity', 'Build agent identity context block'],
|
||
['admin_identity', 'Build admin identity context block'],
|
||
['nostr_admin_profile', 'Build admin profile context block'],
|
||
['nostr_admin_contacts', 'Build admin contacts context block'],
|
||
['nostr_admin_relays', 'Build admin relay context block'],
|
||
['nostr_admin_notes', 'Build admin notes context block'],
|
||
['nostr_agent_profile', 'Build agent profile context block'],
|
||
['nostr_agent_contacts', 'Build agent contacts context block'],
|
||
['nostr_agent_relays', 'Build agent relay context block'],
|
||
['nostr_agent_notes', 'Build agent notes context block'],
|
||
['my_kind0_profile', 'Alias for nostr_agent_profile'],
|
||
['my_contacts', 'Alias for nostr_agent_contacts'],
|
||
['my_relays', 'Alias for nostr_agent_relays'],
|
||
['my_notes', 'Alias for nostr_agent_notes'],
|
||
['skill_create', 'Create or update a skill definition'],
|
||
['skill_edit', 'Edit an existing self skill by d tag'],
|
||
['skill_list', 'List available skills discovered online'],
|
||
['skill_adopt', 'Adopt a skill by adding its address to kind 10123'],
|
||
['skill_remove', 'Remove a skill address from kind 10123'],
|
||
['skill_search', 'Search public skills by query/author'],
|
||
['trigger_list', 'List active triggered skills and runtime status'],
|
||
['task_list', 'Build current task list context block'],
|
||
['task_manage', 'Manage short-term task memory'],
|
||
['memory_save', 'Prepend a new entry to encrypted agent memory'],
|
||
['memory_recall', 'Recall encrypted agent memory'],
|
||
['model_get', 'Get current active LLM runtime configuration'],
|
||
['model_set', 'Update active LLM configuration'],
|
||
['model_list', 'List available model IDs'],
|
||
['config_store', 'Encrypt and publish agent config'],
|
||
['config_recall', 'Fetch and decrypt agent config'],
|
||
['agent_version', 'Return current Didactyl version'],
|
||
['local_http_fetch', 'Fetch HTTP(S) resources'],
|
||
['local_shell_exec', 'Execute a shell command'],
|
||
['local_file_read', 'Read a local file as text'],
|
||
['local_file_write', 'Write text content to a local file'],
|
||
['tool_list', 'List available tools with schemas'],
|
||
['cashu_wallet_balance', 'Return wallet balances by mint and unit'],
|
||
['cashu_wallet_info', 'Fetch mint info'],
|
||
['cashu_wallet_mint_quote', 'Request a mint quote'],
|
||
['cashu_wallet_mint_check', 'Check whether a mint quote is paid/issued'],
|
||
['cashu_wallet_mint_claim', 'Claim minted proofs'],
|
||
['cashu_wallet_melt_quote', 'Request a melt quote'],
|
||
['cashu_wallet_melt_pay', 'Pay a melt quote'],
|
||
['cashu_wallet_check_proofs', 'Ask mint for current proof states'],
|
||
['cashu_wallet_receive_token', 'Receive an ecash token string'],
|
||
['cashu_wallet_send_token', 'Create an outbound ecash token'],
|
||
['cashu_wallet_mints_set', 'Set wallet/public mints'],
|
||
['blossom_upload', 'Upload a local file to a Blossom server'],
|
||
['blossom_download', 'Download a Blossom blob to local path'],
|
||
['blossom_head', 'Fetch Blossom blob metadata by SHA-256'],
|
||
['blossom_delete', 'Delete a Blossom blob by SHA-256'],
|
||
['blossom_list', 'List Blossom blobs for a pubkey'],
|
||
['nostr_post_readme', 'Publish README.md as kind 30023'],
|
||
['nostr_file_md_to_longform_post', 'Read markdown and publish as kind 30023']
|
||
].map(([name, description]) => ({ name, description }));
|
||
|
||
const TOOL_NAME_SET = new Set(DIDACTYL_TOOLS.map(t => t.name));
|
||
|
||
const versionInfo = await getVersion();
|
||
const VERSION = versionInfo.VERSION;
|
||
console.log(`[skills-edit.html ${VERSION}] Loading...`);
|
||
|
||
const AI_STORAGE_KEY = 'ai_chat_openai_config_v1';
|
||
|
||
let updateIntervalId = null;
|
||
let currentPubkey = null;
|
||
let isAuthenticated = false;
|
||
let authMode = 'required';
|
||
let authedPageInitialized = false;
|
||
let relayActivityListenersBound = false;
|
||
|
||
let hamburgerInstance = null;
|
||
let isNavOpen = false;
|
||
let logoutHamburger = null;
|
||
let themeToggleHamburger = null;
|
||
let isDarkMode = false;
|
||
let pageSettings = {};
|
||
let unsubscribeUserSettings = null;
|
||
|
||
let activeTab = 'my';
|
||
let selectedSkillAddress = null;
|
||
let skillsByAddress = new Map();
|
||
let mySkillSub = null;
|
||
let mySkillSubId = null;
|
||
let publicSkillSub = null;
|
||
let publicSkillSubId = null;
|
||
let adoptionSub = null;
|
||
let adoptionSubId = null;
|
||
let publicLoaded = false;
|
||
let adoptedAddresses = [];
|
||
let adoptionEventId = null;
|
||
|
||
let requiresTools = [];
|
||
let optionalTools = [];
|
||
let requiresSkills = [];
|
||
|
||
let aiConfig = loadAiConfigLocal(getDefaultAiConfig(), AI_STORAGE_KEY);
|
||
let fetchedModels = [];
|
||
|
||
const divBody = document.getElementById('divBody');
|
||
const divSideNav = document.getElementById('divSideNav');
|
||
const divFooterCenter = document.getElementById('divFooterCenter');
|
||
const divFooterRight = document.getElementById('divFooterRight');
|
||
|
||
const inpSkillSearch = document.getElementById('inpSkillSearch');
|
||
const btnTabMy = document.getElementById('btnTabMy');
|
||
const btnTabPublic = document.getElementById('btnTabPublic');
|
||
const btnTabAdopted = document.getElementById('btnTabAdopted');
|
||
const btnRefreshSkills = document.getElementById('btnRefreshSkills');
|
||
const btnNewSkill = document.getElementById('btnNewSkill');
|
||
const divSkillsList = document.getElementById('divSkillsList');
|
||
const divAdoptedList = document.getElementById('divAdoptedList');
|
||
|
||
const selSkillKind = document.getElementById('selSkillKind');
|
||
const inpSkillSlug = document.getElementById('inpSkillSlug');
|
||
const inpSkillDescription = document.getElementById('inpSkillDescription');
|
||
const divTriggersList = document.getElementById('divTriggersList');
|
||
const btnAddTrigger = document.getElementById('btnAddTrigger');
|
||
const inpSkillLlm = document.getElementById('inpSkillLlm');
|
||
const inpSkillTemperature = document.getElementById('inpSkillTemperature');
|
||
const inpSkillMaxTokens = document.getElementById('inpSkillMaxTokens');
|
||
const inpSkillSeed = document.getElementById('inpSkillSeed');
|
||
const inpRequiresSkill = document.getElementById('inpRequiresSkill');
|
||
const btnAddRequiresSkill = document.getElementById('btnAddRequiresSkill');
|
||
|
||
const preSkillHighlight = document.getElementById('preSkillHighlight');
|
||
const taSkillContent = document.getElementById('taSkillContent');
|
||
const btnSaveSkill = document.getElementById('btnSaveSkill');
|
||
const btnCopyFork = document.getElementById('btnCopyFork');
|
||
const btnDeleteSkill = document.getElementById('btnDeleteSkill');
|
||
const btnAdoptCurrent = document.getElementById('btnAdoptCurrent');
|
||
const btnRemoveAdoptCurrent = document.getElementById('btnRemoveAdoptCurrent');
|
||
const btnPublishAdoption = document.getElementById('btnPublishAdoption');
|
||
const divEditorStatus = document.getElementById('divEditorStatus');
|
||
|
||
const inpToolSearch = document.getElementById('inpToolSearch');
|
||
const divToolsList = document.getElementById('divToolsList');
|
||
const divTriggerFilterHelper = document.getElementById('divTriggerFilterHelper');
|
||
|
||
const selLlmProvider = document.getElementById('selLlmProvider');
|
||
const inpLlmModelFilter = document.getElementById('inpLlmModelFilter');
|
||
const selLlmModel = document.getElementById('selLlmModel');
|
||
const btnLlmFavorite = document.getElementById('btnLlmFavorite');
|
||
const btnLlmApply = document.getElementById('btnLlmApply');
|
||
const divLlmHelperStatus = document.getElementById('divLlmHelperStatus');
|
||
|
||
const CRON_PRESETS = [
|
||
['Every 5 min', '*/5 * * * *'],
|
||
['Hourly (:00)', '0 * * * *'],
|
||
['Daily noon', '0 12 * * *'],
|
||
['Daily midnight', '0 0 * * *'],
|
||
['Weekdays 9am', '0 9 * * 1-5'],
|
||
['Mondays 9am', '0 9 * * 1']
|
||
];
|
||
|
||
const NOSTR_FILTER_PRESETS = [
|
||
['Mentions admin (kind 1)', '{"#p":["<admin_pubkey>"],"kinds":[1]}'],
|
||
['Notes only (kind 1, limit 20)', '{"kinds":[1],"limit":20}'],
|
||
['Profile updates (kind 0)', '{"kinds":[0]}']
|
||
];
|
||
|
||
const WEBHOOK_FILTER_PRESETS = [
|
||
['Match all', '{}'],
|
||
['POST only', '{"method":"POST"}'],
|
||
['Path /webhook', '{"path":"/webhook"}']
|
||
];
|
||
|
||
function setStatus(msg) {
|
||
divEditorStatus.textContent = String(msg || '');
|
||
}
|
||
|
||
function setLlmHelperStatus(msg) {
|
||
if (divLlmHelperStatus) divLlmHelperStatus.textContent = String(msg || '');
|
||
}
|
||
|
||
function shortPubkey(pubkey) {
|
||
const p = String(pubkey || '');
|
||
if (p.length < 14) return p;
|
||
return `${p.slice(0, 6)}...${p.slice(-4)}`;
|
||
}
|
||
|
||
function normalizeSlug(raw) {
|
||
return String(raw || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9-_]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 64);
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
const span = document.createElement('span');
|
||
span.textContent = String(s || '');
|
||
return span.innerHTML;
|
||
}
|
||
|
||
function getTagValue(tags, key) {
|
||
const t = (Array.isArray(tags) ? tags : []).find(x => Array.isArray(x) && x[0] === key);
|
||
return t && t[1] ? String(t[1]) : '';
|
||
}
|
||
|
||
function getTagValues(tags, key) {
|
||
return (Array.isArray(tags) ? tags : [])
|
||
.filter(x => Array.isArray(x) && x[0] === key && typeof x[1] === 'string')
|
||
.map(x => String(x[1]));
|
||
}
|
||
|
||
function getLlmProviderEntries() {
|
||
return getProviderEntries(aiConfig);
|
||
}
|
||
|
||
function parseLlmToken(value) {
|
||
const raw = String(value || '').trim();
|
||
const first = raw.split(',')[0].trim();
|
||
if (!first) return { provider: '', model: '' };
|
||
const slash = first.indexOf('/');
|
||
if (slash > 0 && slash < first.length - 1) {
|
||
return {
|
||
provider: first.slice(0, slash).trim(),
|
||
model: first.slice(slash + 1).trim()
|
||
};
|
||
}
|
||
return {
|
||
provider: String(aiConfig?.provider || '').trim(),
|
||
model: first
|
||
};
|
||
}
|
||
|
||
function getOrderedModels(filterText = '') {
|
||
const models = Array.isArray(fetchedModels) ? fetchedModels : [];
|
||
const favorites = Array.isArray(aiConfig?.favorites) ? aiConfig.favorites : [];
|
||
const q = String(filterText || '').trim().toLowerCase();
|
||
|
||
const filtered = models.filter((m) => {
|
||
const id = String(m?.id || '').trim();
|
||
if (!id) return false;
|
||
return !q || id.toLowerCase().includes(q);
|
||
});
|
||
|
||
const fav = filtered.filter((m) => favorites.includes(String(m.id || '')));
|
||
const other = filtered.filter((m) => !favorites.includes(String(m.id || '')));
|
||
return { fav, other };
|
||
}
|
||
|
||
function renderLlmProviderOptions() {
|
||
if (!selLlmProvider) return;
|
||
const providers = getLlmProviderEntries();
|
||
const current = String(aiConfig?.provider || '').trim();
|
||
|
||
if (!providers.length) {
|
||
selLlmProvider.innerHTML = '<option value="">(no providers)</option>';
|
||
return;
|
||
}
|
||
|
||
selLlmProvider.innerHTML = providers
|
||
.map((p) => `<option value="${escapeHtml(p.name)}" ${p.name === current ? 'selected' : ''}>${escapeHtml(p.name)}</option>`)
|
||
.join('');
|
||
|
||
if (current) selLlmProvider.value = current;
|
||
}
|
||
|
||
function updateLlmFavoriteButton() {
|
||
const selected = String(selLlmModel?.value || '').trim();
|
||
const isFav = selected && Array.isArray(aiConfig?.favorites) && aiConfig.favorites.includes(selected);
|
||
if (btnLlmFavorite) btnLlmFavorite.textContent = isFav ? '★ Favorited' : '☆ Favorite';
|
||
}
|
||
|
||
function renderLlmModelOptions() {
|
||
if (!selLlmModel) return;
|
||
const selectedBefore = String(selLlmModel.value || '').trim();
|
||
const current = String(selectedBefore || parseLlmToken(inpSkillLlm?.value).model || aiConfig?.model || '').trim();
|
||
const { fav, other } = getOrderedModels(inpLlmModelFilter?.value || '');
|
||
console.log('[skills-edit] renderLlmModelOptions current:', current, 'selectedBefore:', selectedBefore, 'fav:', fav.length, 'other:', other.length);
|
||
|
||
const rows = [];
|
||
for (const m of fav) {
|
||
const id = String(m?.id || '').trim();
|
||
rows.push(`<option value="${escapeHtml(id)}" ${id === current ? 'selected' : ''}>★ ${escapeHtml(id)}</option>`);
|
||
}
|
||
for (const m of other) {
|
||
const id = String(m?.id || '').trim();
|
||
rows.push(`<option value="${escapeHtml(id)}" ${id === current ? 'selected' : ''}>${escapeHtml(id)}</option>`);
|
||
}
|
||
|
||
selLlmModel.innerHTML = rows.join('') || '<option value="">(no models)</option>';
|
||
if (current && Array.from(selLlmModel.options).some((o) => o.value === current)) {
|
||
selLlmModel.value = current;
|
||
console.log('[skills-edit] set selLlmModel.value to:', selLlmModel.value);
|
||
}
|
||
|
||
updateLlmFavoriteButton();
|
||
}
|
||
|
||
function selectLlmProvider(providerName) {
|
||
const name = String(providerName || '').trim();
|
||
if (!name) return;
|
||
const providers = getLlmProviderEntries();
|
||
const selected = providers.find((p) => p.name === name);
|
||
if (!selected) return;
|
||
|
||
aiConfig = {
|
||
...aiConfig,
|
||
provider: selected.name,
|
||
base_url: selected.url,
|
||
api_key: selected.api_key || aiConfig.api_key,
|
||
credit_id: selected.credit_id || aiConfig.credit_id,
|
||
model: selected.model || aiConfig.model,
|
||
max_tokens: Number.isFinite(selected.max_tokens) && selected.max_tokens > 0 ? Math.floor(selected.max_tokens) : aiConfig.max_tokens,
|
||
temperature: Number.isFinite(selected.temperature) ? selected.temperature : aiConfig.temperature
|
||
};
|
||
aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY);
|
||
patchUserSettings({ global_llm: normalizeAiConfig(aiConfig) }).catch((error) => {
|
||
console.warn('[skills-edit.html] failed to persist provider to user-settings:', error);
|
||
});
|
||
renderLlmProviderOptions();
|
||
fetchLlmModels();
|
||
}
|
||
|
||
async function fetchLlmModels() {
|
||
const baseUrl = normalizeBaseUrl(aiConfig?.base_url || '');
|
||
const apiKey = String(aiConfig?.api_key || '').trim();
|
||
|
||
if (!baseUrl) {
|
||
fetchedModels = [];
|
||
renderLlmModelOptions();
|
||
setLlmHelperStatus('No provider base URL configured in AI settings.');
|
||
return;
|
||
}
|
||
if (!apiKey) {
|
||
fetchedModels = [];
|
||
renderLlmModelOptions();
|
||
setLlmHelperStatus('No API key configured for selected provider.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLlmHelperStatus('Loading models...');
|
||
const response = await fetch(`${baseUrl}/v1/models`, {
|
||
headers: { Authorization: `Bearer ${apiKey}` }
|
||
});
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const data = await response.json();
|
||
const seen = new Set();
|
||
fetchedModels = (Array.isArray(data?.data) ? data.data : [])
|
||
.map((m) => ({ id: String(m?.id || '').trim() }))
|
||
.filter((m) => m.id && !seen.has(m.id) && seen.add(m.id))
|
||
.sort((a, b) => a.id.localeCompare(b.id));
|
||
renderLlmModelOptions();
|
||
setLlmHelperStatus(`Loaded ${fetchedModels.length} models from ${baseUrl}.`);
|
||
} catch (error) {
|
||
fetchedModels = [];
|
||
renderLlmModelOptions();
|
||
setLlmHelperStatus(`Model fetch failed: ${String(error?.message || error)}`);
|
||
}
|
||
}
|
||
|
||
function syncLlmHelperFromSkillInput() {
|
||
if (!selLlmProvider || !selLlmModel) return;
|
||
const parsed = parseLlmToken(inpSkillLlm?.value || '');
|
||
if (parsed.provider && Array.from(selLlmProvider.options).some((o) => o.value === parsed.provider)) {
|
||
selLlmProvider.value = parsed.provider;
|
||
}
|
||
if (parsed.model && Array.from(selLlmModel.options).some((o) => o.value === parsed.model)) {
|
||
selLlmModel.value = parsed.model;
|
||
}
|
||
renderLlmModelOptions();
|
||
}
|
||
|
||
function applyLlmSelectionToInput() {
|
||
const provider = String(selLlmProvider?.value || aiConfig?.provider || '').trim();
|
||
const model = String(selLlmModel?.value || '').trim();
|
||
if (!provider || !model) {
|
||
setStatus('Select provider and model first.');
|
||
return;
|
||
}
|
||
inpSkillLlm.value = `${provider}/${model}`;
|
||
setStatus(`Set llm tag to ${inpSkillLlm.value}`);
|
||
syncLlmHelperFromSkillInput();
|
||
}
|
||
|
||
function toggleCurrentModelFavorite() {
|
||
const model = String(selLlmModel?.value || '').trim();
|
||
if (!model) return;
|
||
const favorites = Array.isArray(aiConfig?.favorites) ? [...aiConfig.favorites] : [];
|
||
const idx = favorites.indexOf(model);
|
||
if (idx >= 0) favorites.splice(idx, 1);
|
||
else favorites.push(model);
|
||
aiConfig = { ...aiConfig, favorites };
|
||
aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY);
|
||
patchUserSettings({ global_llm: normalizeAiConfig(aiConfig) }).catch((error) => {
|
||
console.warn('[skills-edit.html] failed to persist favorites to user-settings:', error);
|
||
});
|
||
renderLlmModelOptions();
|
||
}
|
||
|
||
function syncProviderFromTopLevel() {
|
||
const providerName = String(aiConfig.provider || '').trim();
|
||
if (!providerName) return;
|
||
const providers = Array.isArray(aiConfig.providers) ? aiConfig.providers : [];
|
||
const idx = providers.findIndex(p => String(p.name || '').trim() === providerName);
|
||
if (idx >= 0) {
|
||
providers[idx] = {
|
||
...providers[idx],
|
||
api_key: aiConfig.api_key || providers[idx].api_key,
|
||
credit_id: aiConfig.credit_id || providers[idx].credit_id,
|
||
model: aiConfig.model || providers[idx].model,
|
||
max_tokens: aiConfig.max_tokens || providers[idx].max_tokens,
|
||
temperature: aiConfig.temperature ?? providers[idx].temperature
|
||
};
|
||
aiConfig.providers = providers;
|
||
}
|
||
}
|
||
|
||
async function initializeLlmHelperFromSettings() {
|
||
aiConfig = loadAiConfigLocal(aiConfig || getDefaultAiConfig(), AI_STORAGE_KEY);
|
||
|
||
const llmNode = pageSettings?.global_llm || pageSettings?.ai || null;
|
||
if (llmNode && typeof llmNode === 'object') {
|
||
aiConfig = mergeAiConfigFromSettings(aiConfig, llmNode);
|
||
}
|
||
|
||
syncProviderFromTopLevel();
|
||
aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY);
|
||
renderLlmProviderOptions();
|
||
renderLlmModelOptions();
|
||
syncLlmHelperFromSkillInput();
|
||
fetchLlmModels();
|
||
}
|
||
|
||
function parseAddress(address) {
|
||
const parts = String(address || '').split(':');
|
||
if (parts.length < 3) return null;
|
||
const kind = Number(parts[0]);
|
||
const pubkey = parts[1] || '';
|
||
const dTag = parts.slice(2).join(':');
|
||
if (!Number.isFinite(kind) || !pubkey || !dTag) return null;
|
||
return { kind, pubkey, dTag };
|
||
}
|
||
|
||
function makeAddress(kind, pubkey, dTag) {
|
||
return `${Number(kind)}:${String(pubkey || '').trim()}:${String(dTag || '').trim()}`;
|
||
}
|
||
|
||
function isAddressAdopted(address) {
|
||
return adoptedAddresses.includes(String(address || ''));
|
||
}
|
||
|
||
function cronPartsFromFilter(filterRaw) {
|
||
const parts = String(filterRaw || '').trim().split(/\s+/);
|
||
if (parts.length === 5) return parts;
|
||
return ['*', '*', '*', '*', '*'];
|
||
}
|
||
|
||
function dmFromFromFilter(filterRaw) {
|
||
try {
|
||
const parsed = JSON.parse(String(filterRaw || '{}'));
|
||
const from = String(parsed?.from || 'admin');
|
||
if (['admin', 'wot', 'any'].includes(from)) return from;
|
||
} catch (_error) {}
|
||
return 'admin';
|
||
}
|
||
|
||
let triggers = [];
|
||
|
||
function renderTriggers() {
|
||
divTriggersList.innerHTML = triggers.map((t, i) => `
|
||
<div class="triggerHelpRow">
|
||
<select class="sel selTriggerType" data-i="${i}">
|
||
<option value="dm" ${t.type === 'dm' ? 'selected' : ''}>dm</option>
|
||
<option value="cron" ${t.type === 'cron' ? 'selected' : ''}>cron</option>
|
||
<option value="nostr-subscription" ${t.type === 'nostr-subscription' ? 'selected' : ''}>nostr-subscription</option>
|
||
<option value="webhook" ${t.type === 'webhook' ? 'selected' : ''}>webhook</option>
|
||
<option value="chain" ${t.type === 'chain' ? 'selected' : ''}>chain</option>
|
||
</select>
|
||
<input class="inp inpTriggerFilter" type="text" data-i="${i}" value="${escapeHtml(t.filter || '')}" placeholder="filter..." />
|
||
<button class="btn btnRemoveTrigger" type="button" data-i="${i}">×</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
divTriggersList.querySelectorAll('.selTriggerType').forEach(el => el.addEventListener('change', (e) => {
|
||
triggers[e.target.dataset.i].type = e.target.value;
|
||
}));
|
||
divTriggersList.querySelectorAll('.inpTriggerFilter').forEach(el => el.addEventListener('input', (e) => {
|
||
triggers[e.target.dataset.i].filter = e.target.value;
|
||
}));
|
||
divTriggersList.querySelectorAll('.btnRemoveTrigger').forEach(el => el.addEventListener('click', (e) => {
|
||
triggers.splice(e.target.dataset.i, 1);
|
||
renderTriggers();
|
||
}));
|
||
}
|
||
|
||
function toggleTabUI() {
|
||
btnTabMy.classList.toggle('active', activeTab === 'my');
|
||
btnTabPublic.classList.toggle('active', activeTab === 'public');
|
||
btnTabAdopted.classList.toggle('active', activeTab === 'adopted');
|
||
}
|
||
|
||
function updateAddRequiresSkillButtonState() {
|
||
if (!btnAddRequiresSkill) return;
|
||
const hasValue = Boolean(normalizeSlug(inpRequiresSkill?.value || ''));
|
||
btnAddRequiresSkill.disabled = !hasValue;
|
||
}
|
||
|
||
function clearEditor() {
|
||
selectedSkillAddress = null;
|
||
selSkillKind.value = '31123';
|
||
inpSkillSlug.value = '';
|
||
inpSkillDescription.value = '';
|
||
triggers = [];
|
||
renderTriggers();
|
||
inpSkillLlm.value = '';
|
||
inpSkillTemperature.value = '';
|
||
inpSkillMaxTokens.value = '';
|
||
inpSkillSeed.value = '';
|
||
taSkillContent.value = '## Purpose\nYou are a helpful assistant.\n\nInput:\n{{message}}';
|
||
requiresTools = [];
|
||
optionalTools = [];
|
||
requiresSkills = [];
|
||
inpRequiresSkill.value = '';
|
||
renderTagChips();
|
||
renderHighlight();
|
||
renderTools();
|
||
syncLlmHelperFromSkillInput();
|
||
updateAddRequiresSkillButtonState();
|
||
setStatus('New skill ready.');
|
||
btnCopyFork.style.display = 'none';
|
||
}
|
||
function renderTagChips() {
|
||
// Tag chips panel intentionally removed from UI to save space.
|
||
// Keep this as a no-op so existing workflows can continue calling it safely.
|
||
}
|
||
|
||
|
||
function getVisibleSkills() {
|
||
const q = String(inpSkillSearch.value || '').trim().toLowerCase();
|
||
const skills = Array.from(skillsByAddress.values());
|
||
let scoped = [];
|
||
|
||
if (activeTab === 'my') {
|
||
scoped = skills.filter(s => s.author === currentPubkey);
|
||
} else if (activeTab === 'public') {
|
||
scoped = skills.filter(s => s.kind === 31123 && s.author !== currentPubkey);
|
||
} else {
|
||
scoped = adoptedAddresses
|
||
.map(addr => skillsByAddress.get(addr) || {
|
||
address: addr,
|
||
slug: parseAddress(addr)?.dTag || addr,
|
||
description: '(not loaded yet)',
|
||
author: parseAddress(addr)?.pubkey || '',
|
||
kind: parseAddress(addr)?.kind || 31123,
|
||
missing: true,
|
||
created_at: 0
|
||
});
|
||
}
|
||
|
||
if (q) {
|
||
scoped = scoped.filter((s) => {
|
||
const text = `${s.slug || ''} ${s.description || ''} ${s.author || ''}`.toLowerCase();
|
||
return text.includes(q);
|
||
});
|
||
}
|
||
|
||
if (activeTab !== 'adopted') {
|
||
scoped.sort((a, b) => {
|
||
const ta = Number(a.created_at || 0);
|
||
const tb = Number(b.created_at || 0);
|
||
if (tb !== ta) return tb - ta;
|
||
return String(a.slug || '').localeCompare(String(b.slug || ''));
|
||
});
|
||
}
|
||
|
||
return scoped;
|
||
}
|
||
|
||
function renderSkillsList() {
|
||
toggleTabUI();
|
||
const list = getVisibleSkills();
|
||
if (!list.length) {
|
||
divSkillsList.innerHTML = '<div class="hint">No skills in this tab.</div>';
|
||
return;
|
||
}
|
||
|
||
divSkillsList.innerHTML = list.map((s) => {
|
||
const adopted = isAddressAdopted(s.address);
|
||
const active = selectedSkillAddress === s.address ? 'active' : '';
|
||
const author = shortPubkey(s.author);
|
||
return `
|
||
<div class="skillItem ${active}" data-address="${escapeHtml(s.address)}">
|
||
<div class="skillItemTitle">
|
||
<span>${escapeHtml(s.slug || '(missing slug)')}</span>
|
||
<span>${adopted ? '<span class="chipStar">★</span>' : ''}</span>
|
||
</div>
|
||
<div class="skillItemMeta">${escapeHtml(s.description || '')}</div>
|
||
<div class="skillItemMeta">${escapeHtml(author)} · ${escapeHtml(String(s.kind || ''))}${s.missing ? ' · missing' : ''}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
Array.from(divSkillsList.querySelectorAll('.skillItem[data-address]')).forEach((el) => {
|
||
el.addEventListener('click', () => {
|
||
const address = String(el.getAttribute('data-address') || '');
|
||
loadSkillIntoEditor(address);
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderAdoptedList() {
|
||
if (!adoptedAddresses.length) {
|
||
divAdoptedList.innerHTML = '<div class="hint">No adopted skills yet.</div>';
|
||
return;
|
||
}
|
||
|
||
divAdoptedList.innerHTML = adoptedAddresses.map((addr, i) => {
|
||
const s = skillsByAddress.get(addr);
|
||
const label = s ? `${s.slug} (${shortPubkey(s.author)})` : (parseAddress(addr)?.dTag || addr);
|
||
return `
|
||
<div class="adoptItem" data-address="${escapeHtml(addr)}">
|
||
<div>${escapeHtml(label)}</div>
|
||
<div class="adoptActions">
|
||
<button class="miniBtn" type="button" data-act="up" data-i="${i}">↑</button>
|
||
<button class="miniBtn" type="button" data-act="down" data-i="${i}">↓</button>
|
||
<button class="miniBtn" type="button" data-act="remove" data-i="${i}">×</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
Array.from(divAdoptedList.querySelectorAll('button[data-act][data-i]')).forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const i = Number(btn.getAttribute('data-i'));
|
||
const act = String(btn.getAttribute('data-act') || '');
|
||
if (!Number.isFinite(i) || i < 0 || i >= adoptedAddresses.length) return;
|
||
if (act === 'remove') {
|
||
adoptedAddresses.splice(i, 1);
|
||
} else if (act === 'up' && i > 0) {
|
||
const tmp = adoptedAddresses[i - 1];
|
||
adoptedAddresses[i - 1] = adoptedAddresses[i];
|
||
adoptedAddresses[i] = tmp;
|
||
} else if (act === 'down' && i < adoptedAddresses.length - 1) {
|
||
const tmp = adoptedAddresses[i + 1];
|
||
adoptedAddresses[i + 1] = adoptedAddresses[i];
|
||
adoptedAddresses[i] = tmp;
|
||
}
|
||
renderAdoptedList();
|
||
renderSkillsList();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderTools() {
|
||
const q = String(inpToolSearch.value || '').trim().toLowerCase();
|
||
const filtered = DIDACTYL_TOOLS.filter((t) => {
|
||
if (!q) return true;
|
||
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
|
||
});
|
||
|
||
divToolsList.innerHTML = filtered.map((t) => {
|
||
const required = requiresTools.includes(t.name) ? 'required' : '';
|
||
return `
|
||
<div class="toolItem ${required}" data-tool="${escapeHtml(t.name)}">
|
||
<div class="toolName">${escapeHtml(t.name)}</div>
|
||
<div class="toolDesc">${escapeHtml(t.description)}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
Array.from(divToolsList.querySelectorAll('.toolItem[data-tool]')).forEach((el) => {
|
||
el.addEventListener('dblclick', () => {
|
||
const tool = String(el.getAttribute('data-tool') || '');
|
||
if (!tool) return;
|
||
if (!requiresTools.includes(tool)) requiresTools.push(tool);
|
||
insertAtCursor(taSkillContent, `{{${tool}}}`);
|
||
renderTagChips();
|
||
renderTools();
|
||
renderHighlight();
|
||
setStatus(`Added requires_tool and inserted {{${tool}}}`);
|
||
});
|
||
});
|
||
}
|
||
|
||
function insertAtCursor(textarea, text) {
|
||
const start = textarea.selectionStart ?? textarea.value.length;
|
||
const end = textarea.selectionEnd ?? textarea.value.length;
|
||
const before = textarea.value.slice(0, start);
|
||
const after = textarea.value.slice(end);
|
||
textarea.value = `${before}${text}${after}`;
|
||
const next = start + text.length;
|
||
textarea.selectionStart = next;
|
||
textarea.selectionEnd = next;
|
||
textarea.focus();
|
||
}
|
||
|
||
function renderHighlight() {
|
||
let html = escapeHtml(taSkillContent.value || '');
|
||
|
||
html = html.replace(/\{\{\s*([a-zA-Z0-9_-]+)\s*\}\}/g, (m, name) => {
|
||
if (TOOL_NAME_SET.has(name)) {
|
||
return `<span class="hl-tool">{{${escapeHtml(name)}}}</span>`;
|
||
}
|
||
return `<span class="hl-var">{{${escapeHtml(name)}}}</span>`;
|
||
});
|
||
|
||
preSkillHighlight.innerHTML = html + '\n';
|
||
}
|
||
|
||
function syncEditorScroll() {
|
||
preSkillHighlight.scrollTop = taSkillContent.scrollTop;
|
||
preSkillHighlight.scrollLeft = taSkillContent.scrollLeft;
|
||
}
|
||
|
||
function upsertSkill(skill) {
|
||
if (!skill || !skill.address) return;
|
||
const prev = skillsByAddress.get(skill.address);
|
||
if (!prev || Number(skill.created_at || 0) >= Number(prev.created_at || 0)) {
|
||
skillsByAddress.set(skill.address, skill);
|
||
}
|
||
}
|
||
|
||
async function parseSkillEvent(evt) {
|
||
if (!evt || (evt.kind !== 31123 && evt.kind !== 31124)) return null;
|
||
const slug = normalizeSlug(getTagValue(evt.tags, 'd'));
|
||
if (!slug) return null;
|
||
|
||
let content = String(evt.content || '');
|
||
let tags = Array.isArray(evt.tags) ? evt.tags.map(t => Array.isArray(t) ? [...t] : t) : [];
|
||
let decryptError = '';
|
||
|
||
if (evt.kind === 31124) {
|
||
try {
|
||
if (!window.nostr?.nip44?.decrypt) throw new Error('nip44 decrypt unavailable in signer');
|
||
const plaintext = await window.nostr.nip44.decrypt(String(evt.pubkey || ''), String(evt.content || ''));
|
||
const parsed = JSON.parse(String(plaintext || '{}'));
|
||
content = String(parsed?.content || '');
|
||
const privateTags = Array.isArray(parsed?.private_tags) ? parsed.private_tags : [];
|
||
tags = [['d', slug], ...privateTags.filter(t => Array.isArray(t) && t.length >= 2)];
|
||
} catch (error) {
|
||
decryptError = String(error?.message || error || 'decrypt failed');
|
||
}
|
||
}
|
||
|
||
const address = makeAddress(evt.kind, evt.pubkey, slug);
|
||
return {
|
||
address,
|
||
key: `${address}`,
|
||
kind: Number(evt.kind),
|
||
slug,
|
||
description: getTagValue(tags, 'description') || slug,
|
||
triggers: getTagValues(tags, 'trigger').map((type, index) => ({
|
||
type,
|
||
filter: getTagValues(tags, 'filter')[index] || ''
|
||
})),
|
||
trigger: getTagValue(tags, 'trigger'),
|
||
filter: getTagValue(tags, 'filter'),
|
||
llm: getTagValue(tags, 'llm'),
|
||
temperature: getTagValue(tags, 'temperature'),
|
||
max_tokens: getTagValue(tags, 'max_tokens'),
|
||
seed: getTagValue(tags, 'seed'),
|
||
requires_tool: getTagValues(tags, 'requires_tool'),
|
||
optional_tool: getTagValues(tags, 'optional_tool'),
|
||
requires_skill: getTagValues(tags, 'requires_skill'),
|
||
content,
|
||
rawTags: tags,
|
||
rawEvent: evt,
|
||
author: String(evt.pubkey || ''),
|
||
created_at: Number(evt.created_at || 0),
|
||
decryptError
|
||
};
|
||
}
|
||
|
||
function loadSkillIntoEditor(address) {
|
||
const skill = skillsByAddress.get(address);
|
||
selectedSkillAddress = address;
|
||
renderSkillsList();
|
||
if (!skill) {
|
||
setStatus('Skill record is not loaded yet.');
|
||
return;
|
||
}
|
||
|
||
selSkillKind.value = String(skill.kind || 31123);
|
||
inpSkillSlug.value = skill.slug || '';
|
||
inpSkillDescription.value = skill.description || '';
|
||
triggers = skill.triggers || [];
|
||
renderTriggers();
|
||
inpSkillLlm.value = skill.llm || '';
|
||
syncLlmHelperFromSkillInput();
|
||
inpSkillTemperature.value = skill.temperature || '';
|
||
inpSkillMaxTokens.value = skill.max_tokens || '';
|
||
inpSkillSeed.value = skill.seed || '';
|
||
taSkillContent.value = String(skill.content || '');
|
||
requiresTools = [...(skill.requires_tool || [])];
|
||
optionalTools = [...(skill.optional_tool || [])];
|
||
requiresSkills = [...(skill.requires_skill || [])];
|
||
inpRequiresSkill.value = '';
|
||
|
||
renderTagChips();
|
||
renderTools();
|
||
renderHighlight();
|
||
syncEditorScroll();
|
||
updateAddRequiresSkillButtonState();
|
||
|
||
const isForeignPublic = skill.kind === 31123 && skill.author && currentPubkey && skill.author !== currentPubkey;
|
||
btnCopyFork.style.display = isForeignPublic ? '' : 'none';
|
||
|
||
if (skill.decryptError) {
|
||
setStatus(`Loaded ${skill.slug} with decrypt warning: ${skill.decryptError}`);
|
||
} else {
|
||
setStatus(`Loaded ${skill.slug}`);
|
||
}
|
||
}
|
||
|
||
async function handleIncomingSkillEvent(evt) {
|
||
if (isEventMuted(evt)) return;
|
||
const parsed = await parseSkillEvent(evt);
|
||
if (!parsed) return;
|
||
upsertSkill(parsed);
|
||
renderSkillsList();
|
||
renderAdoptedList();
|
||
if (selectedSkillAddress === parsed.address) {
|
||
loadSkillIntoEditor(parsed.address);
|
||
}
|
||
}
|
||
|
||
function parseAdoptionEvent(evt) {
|
||
if (!evt || evt.kind !== 10123 || evt.pubkey !== currentPubkey) return;
|
||
const tags = Array.isArray(evt.tags) ? evt.tags : [];
|
||
adoptedAddresses = tags
|
||
.filter(t => Array.isArray(t) && t[0] === 'a' && typeof t[1] === 'string')
|
||
.map(t => String(t[1]));
|
||
adoptionEventId = String(evt.id || '');
|
||
renderAdoptedList();
|
||
renderSkillsList();
|
||
}
|
||
|
||
function buildSkillTags() {
|
||
const tags = [];
|
||
const slug = normalizeSlug(inpSkillSlug.value);
|
||
const description = String(inpSkillDescription.value || '').trim();
|
||
const llm = String(inpSkillLlm.value || '').trim();
|
||
const temperature = String(inpSkillTemperature.value || '').trim();
|
||
const maxTokens = String(inpSkillMaxTokens.value || '').trim();
|
||
const seed = String(inpSkillSeed.value || '').trim();
|
||
|
||
tags.push(['d', slug]);
|
||
if (description) tags.push(['description', description]);
|
||
for (const t of triggers) {
|
||
tags.push(['trigger', t.type]);
|
||
tags.push(['filter', t.filter]);
|
||
}
|
||
if (llm) tags.push(['llm', llm]);
|
||
if (temperature) tags.push(['temperature', temperature]);
|
||
if (maxTokens) tags.push(['max_tokens', maxTokens]);
|
||
if (seed) tags.push(['seed', seed]);
|
||
|
||
for (const t of requiresTools) tags.push(['requires_tool', t]);
|
||
for (const t of optionalTools) tags.push(['optional_tool', t]);
|
||
for (const s of requiresSkills) tags.push(['requires_skill', s]);
|
||
|
||
return tags;
|
||
}
|
||
|
||
async function saveSkill() {
|
||
const kind = Number(selSkillKind.value || 31123);
|
||
if (kind !== 31123 && kind !== 31124) {
|
||
return setStatus('Invalid kind. Select 31123 or 31124.');
|
||
}
|
||
|
||
const slug = normalizeSlug(inpSkillSlug.value);
|
||
if (!slug) return setStatus('Slug is required.');
|
||
const content = String(taSkillContent.value || '').trim();
|
||
if (!content) return setStatus('Skill content is required.');
|
||
const now = Math.floor(Date.now() / 1000);
|
||
|
||
try {
|
||
if (kind === 31123) {
|
||
const event = {
|
||
kind: 31123,
|
||
content,
|
||
tags: buildSkillTags(),
|
||
created_at: now
|
||
};
|
||
await publishEvent(event);
|
||
setStatus(`Published skill (31123): ${slug}`);
|
||
} else {
|
||
if (!window.nostr?.nip44?.encrypt) {
|
||
throw new Error('NIP-44 encryption unavailable in signer');
|
||
}
|
||
const allTags = buildSkillTags();
|
||
const privateTags = allTags.filter((t) => Array.isArray(t) && t[0] !== 'd');
|
||
const payload = JSON.stringify({
|
||
content,
|
||
private_tags: privateTags
|
||
});
|
||
const ciphertext = await window.nostr.nip44.encrypt(currentPubkey, payload);
|
||
const event = {
|
||
kind: 31124,
|
||
content: String(ciphertext || ''),
|
||
tags: [['d', slug]],
|
||
created_at: now
|
||
};
|
||
await publishEvent(event);
|
||
setStatus(`Published skill (31124): ${slug}`);
|
||
}
|
||
|
||
refreshSkills();
|
||
} catch (error) {
|
||
setStatus(`Save failed: ${String(error?.message || error)}`);
|
||
}
|
||
}
|
||
|
||
async function deleteSelectedSkill() {
|
||
const skill = selectedSkillAddress ? skillsByAddress.get(selectedSkillAddress) : null;
|
||
if (!skill) return setStatus('Select a skill first.');
|
||
if (skill.author !== currentPubkey) return setStatus('You can only delete your own skills.');
|
||
if (!confirm(`Delete skill "${skill.slug}"?`)) return;
|
||
|
||
const event = {
|
||
kind: 5,
|
||
content: '',
|
||
tags: [['a', skill.address]],
|
||
created_at: Math.floor(Date.now() / 1000)
|
||
};
|
||
if (skill.rawEvent?.id) event.tags.push(['e', skill.rawEvent.id]);
|
||
|
||
try {
|
||
await publishEvent(event);
|
||
setStatus(`Deletion request published for ${skill.slug}`);
|
||
} catch (error) {
|
||
setStatus(`Delete failed: ${String(error?.message || error)}`);
|
||
}
|
||
}
|
||
|
||
function currentEditorAddress() {
|
||
const kind = Number(selSkillKind.value || 31123);
|
||
const slug = normalizeSlug(inpSkillSlug.value);
|
||
if (!slug || !currentPubkey) return null;
|
||
return makeAddress(kind, currentPubkey, slug);
|
||
}
|
||
|
||
async function publishAdoptionList() {
|
||
try {
|
||
const event = {
|
||
kind: 10123,
|
||
content: '',
|
||
tags: adoptedAddresses.map((addr) => ['a', addr]),
|
||
created_at: Math.floor(Date.now() / 1000)
|
||
};
|
||
if (adoptionEventId) event.tags.push(['e', adoptionEventId]);
|
||
await publishEvent(event);
|
||
setStatus(`Published adoption list (${adoptedAddresses.length} skills).`);
|
||
} catch (error) {
|
||
setStatus(`Publish 10123 failed: ${String(error?.message || error)}`);
|
||
}
|
||
}
|
||
|
||
async function adoptCurrentSkill() {
|
||
let addr = selectedSkillAddress;
|
||
if (!addr) addr = currentEditorAddress();
|
||
if (!addr) return setStatus('Need selected skill or valid slug/kind to adopt.');
|
||
if (!adoptedAddresses.includes(addr)) adoptedAddresses.push(addr);
|
||
renderAdoptedList();
|
||
renderSkillsList();
|
||
await publishAdoptionList();
|
||
}
|
||
|
||
async function removeCurrentAdoptedSkill() {
|
||
let addr = selectedSkillAddress;
|
||
if (!addr) addr = currentEditorAddress();
|
||
if (!addr) return setStatus('Need selected skill or valid slug/kind.');
|
||
adoptedAddresses = adoptedAddresses.filter((x) => x !== addr);
|
||
renderAdoptedList();
|
||
renderSkillsList();
|
||
await publishAdoptionList();
|
||
}
|
||
|
||
function copySelectedSkillToMine() {
|
||
const skill = selectedSkillAddress ? skillsByAddress.get(selectedSkillAddress) : null;
|
||
if (!skill) return setStatus('Select a public skill to copy.');
|
||
if (skill.kind !== 31123 || skill.author === currentPubkey) return setStatus('Copy is for public skills by other authors.');
|
||
|
||
const nextSlug = normalizeSlug(`${skill.slug}-copy`) || `skill-copy-${Date.now()}`;
|
||
selSkillKind.value = '31123';
|
||
inpSkillSlug.value = nextSlug;
|
||
inpSkillDescription.value = `${skill.description || skill.slug} (copy)`;
|
||
triggers = (Array.isArray(skill.triggers) && skill.triggers.length)
|
||
? skill.triggers
|
||
: (skill.trigger ? [{ type: skill.trigger, filter: skill.filter || '' }] : []);
|
||
renderTriggers();
|
||
inpSkillLlm.value = skill.llm || '';
|
||
syncLlmHelperFromSkillInput();
|
||
inpSkillTemperature.value = skill.temperature || '';
|
||
inpSkillMaxTokens.value = skill.max_tokens || '';
|
||
inpSkillSeed.value = skill.seed || '';
|
||
taSkillContent.value = skill.content || '';
|
||
requiresTools = [...(skill.requires_tool || [])];
|
||
optionalTools = [...(skill.optional_tool || [])];
|
||
requiresSkills = [...(skill.requires_skill || [])];
|
||
selectedSkillAddress = null;
|
||
renderTagChips();
|
||
renderTools();
|
||
renderHighlight();
|
||
renderSkillsList();
|
||
btnCopyFork.style.display = 'none';
|
||
setStatus(`Copied ${skill.slug} to editor as ${nextSlug}. Save to publish.`);
|
||
}
|
||
|
||
function clearSub(refName) {
|
||
try {
|
||
const sub = (refName === 'my') ? mySkillSub : (refName === 'public') ? publicSkillSub : adoptionSub;
|
||
if (sub && typeof sub.close === 'function') sub.close();
|
||
} catch (_error) {}
|
||
if (refName === 'my') {
|
||
mySkillSub = null;
|
||
mySkillSubId = null;
|
||
} else if (refName === 'public') {
|
||
publicSkillSub = null;
|
||
publicSkillSubId = null;
|
||
} else {
|
||
adoptionSub = null;
|
||
adoptionSubId = null;
|
||
}
|
||
}
|
||
|
||
function refreshMySkills() {
|
||
clearSub('my');
|
||
mySkillSub = subscribe({ kinds: [31123, 31124], authors: [currentPubkey], limit: 400 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||
mySkillSubId = mySkillSub?.subId || null;
|
||
}
|
||
|
||
function refreshPublicSkills() {
|
||
clearSub('public');
|
||
publicSkillSub = subscribe({ kinds: [31123], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||
publicSkillSubId = publicSkillSub?.subId || null;
|
||
publicLoaded = true;
|
||
}
|
||
|
||
function refreshAdoption() {
|
||
clearSub('adoption');
|
||
adoptionSub = subscribe({ kinds: [10123], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||
adoptionSubId = adoptionSub?.subId || null;
|
||
}
|
||
|
||
function refreshSkills() {
|
||
setStatus('Refreshing skills...');
|
||
refreshMySkills();
|
||
refreshAdoption();
|
||
if (activeTab === 'public' || publicLoaded) refreshPublicSkills();
|
||
}
|
||
|
||
function bindSkillEventListeners() {
|
||
window.addEventListener('ndkEvent', async (event) => {
|
||
const evt = event?.detail;
|
||
if (!evt) return;
|
||
if (evt.kind === 10123 && evt.pubkey === currentPubkey) {
|
||
parseAdoptionEvent(evt);
|
||
return;
|
||
}
|
||
if (evt.kind === 31123 || evt.kind === 31124) {
|
||
await handleIncomingSkillEvent(evt);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('ndkEose', (event) => {
|
||
const sid = event?.detail?.subId || null;
|
||
if (!sid) return;
|
||
if (sid === mySkillSubId || sid === publicSkillSubId || sid === adoptionSubId) {
|
||
if (!getVisibleSkills().length) {
|
||
setStatus(activeTab === 'public' ? 'No public skills found yet.' : 'No skills found yet.');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function bindUi() {
|
||
btnTabMy.addEventListener('click', () => {
|
||
activeTab = 'my';
|
||
renderSkillsList();
|
||
});
|
||
btnTabPublic.addEventListener('click', () => {
|
||
activeTab = 'public';
|
||
if (!publicLoaded) refreshPublicSkills();
|
||
renderSkillsList();
|
||
});
|
||
btnTabAdopted.addEventListener('click', () => {
|
||
activeTab = 'adopted';
|
||
renderSkillsList();
|
||
});
|
||
|
||
inpSkillSearch.addEventListener('input', renderSkillsList);
|
||
inpToolSearch.addEventListener('input', renderTools);
|
||
selLlmProvider?.addEventListener('change', () => selectLlmProvider(selLlmProvider.value));
|
||
selLlmModel?.addEventListener('change', () => {
|
||
console.log('[skills-edit] selLlmModel changed to:', selLlmModel.value);
|
||
updateLlmFavoriteButton();
|
||
});
|
||
inpLlmModelFilter?.addEventListener('input', renderLlmModelOptions);
|
||
btnLlmFavorite?.addEventListener('click', toggleCurrentModelFavorite);
|
||
btnLlmApply?.addEventListener('click', applyLlmSelectionToInput);
|
||
|
||
btnRefreshSkills.addEventListener('click', refreshSkills);
|
||
btnNewSkill.addEventListener('click', clearEditor);
|
||
btnAddTrigger.addEventListener('click', () => {
|
||
triggers.push({ type: 'dm', filter: '' });
|
||
renderTriggers();
|
||
});
|
||
|
||
taSkillContent.addEventListener('input', renderHighlight);
|
||
taSkillContent.addEventListener('scroll', syncEditorScroll);
|
||
window.addEventListener('resize', syncEditorScroll);
|
||
|
||
inpRequiresSkill.addEventListener('input', updateAddRequiresSkillButtonState);
|
||
btnAddRequiresSkill.addEventListener('click', () => {
|
||
const v = normalizeSlug(inpRequiresSkill.value);
|
||
if (!v) return;
|
||
if (!requiresSkills.includes(v)) requiresSkills.push(v);
|
||
inpRequiresSkill.value = '';
|
||
updateAddRequiresSkillButtonState();
|
||
renderTagChips();
|
||
});
|
||
|
||
btnSaveSkill.addEventListener('click', saveSkill);
|
||
btnDeleteSkill.addEventListener('click', deleteSelectedSkill);
|
||
btnCopyFork.addEventListener('click', copySelectedSkillToMine);
|
||
btnAdoptCurrent.addEventListener('click', adoptCurrentSkill);
|
||
btnRemoveAdoptCurrent.addEventListener('click', removeCurrentAdoptedSkill);
|
||
btnPublishAdoption.addEventListener('click', publishAdoptionList);
|
||
|
||
btnCopyFork.style.display = 'none';
|
||
syncLlmHelperFromSkillInput();
|
||
updateAddRequiresSkillButtonState();
|
||
}
|
||
|
||
function initHamburgerMenu() {
|
||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||
foreground: 'var(--primary-color)',
|
||
background: 'var(--secondary-color)',
|
||
hover: 'var(--accent-color)'
|
||
});
|
||
hamburgerInstance.animateTo('burger');
|
||
}
|
||
|
||
function openNav() {
|
||
divSideNav.style.zIndex = 3;
|
||
divSideNav.style.width = '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();
|
||
}
|
||
|
||
function hasTargetPubkeyInUrl() {
|
||
const params = new URLSearchParams(window.location.search || '');
|
||
return Boolean(String(params.get('npub') || '').trim() || String(params.get('pubkey') || '').trim());
|
||
}
|
||
|
||
function resolveAuthModeFromUrl() {
|
||
const params = new URLSearchParams(window.location.search || '');
|
||
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
|
||
if (['required', 'optional', 'none'].includes(explicitAuth)) return explicitAuth;
|
||
if (hasTargetPubkeyInUrl()) return 'optional';
|
||
return 'required';
|
||
}
|
||
|
||
function isAuthRequiredError(error) {
|
||
const message = String(error?.message || error || '').toLowerCase();
|
||
return message.includes('authentication required');
|
||
}
|
||
|
||
async function initializeAuthentication(mode) {
|
||
if (mode === 'required') {
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
return;
|
||
}
|
||
if (mode === 'none') {
|
||
isAuthenticated = false;
|
||
currentPubkey = null;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
} catch (error) {
|
||
if (isAuthRequiredError(error)) {
|
||
isAuthenticated = false;
|
||
currentPubkey = null;
|
||
return;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function UpdateFooter() {
|
||
try {
|
||
await updateFooterRelayStatus();
|
||
await updateSidenavRelaySection();
|
||
await updateBlossomSection();
|
||
divFooterCenter.innerHTML = '';
|
||
divFooterRight.innerHTML = '';
|
||
} catch (error) {
|
||
console.error('[skills-edit.html] footer update error:', error);
|
||
}
|
||
}
|
||
|
||
async function initializeAuthenticatedPageFeatures() {
|
||
if (!isAuthenticated || authedPageInitialized) return;
|
||
|
||
await injectHeaderAvatar(currentPubkey);
|
||
|
||
try {
|
||
pageSettings = await getUserSettings();
|
||
} catch (_error) {
|
||
pageSettings = {};
|
||
}
|
||
|
||
if (!unsubscribeUserSettings) {
|
||
unsubscribeUserSettings = onUserSettings((settings) => {
|
||
pageSettings = settings || {};
|
||
initializeLlmHelperFromSettings();
|
||
});
|
||
}
|
||
|
||
initFooterRelayStatus();
|
||
initSidenavRelaySection();
|
||
await initBlossomSection();
|
||
initAiSectionWithLocalConfig();
|
||
initializeLlmHelperFromSettings();
|
||
await UpdateFooter();
|
||
if (!updateIntervalId) updateIntervalId = setInterval(UpdateFooter, 1000);
|
||
|
||
if (!relayActivityListenersBound) {
|
||
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);
|
||
}
|
||
});
|
||
|
||
relayActivityListenersBound = true;
|
||
}
|
||
|
||
bindUi();
|
||
bindSkillEventListeners();
|
||
renderTools();
|
||
clearEditor();
|
||
await ensureMuteListLoaded();
|
||
refreshSkills();
|
||
renderSkillsList();
|
||
renderAdoptedList();
|
||
|
||
authedPageInitialized = true;
|
||
}
|
||
|
||
async function promptLoginIfNeeded() {
|
||
if (isAuthenticated) return true;
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
await initializeAuthenticatedPageFeatures();
|
||
return true;
|
||
}
|
||
|
||
const Logout = async () => {
|
||
if (updateIntervalId) {
|
||
clearInterval(updateIntervalId);
|
||
updateIntervalId = null;
|
||
}
|
||
clearSub('my');
|
||
clearSub('public');
|
||
clearSub('adoption');
|
||
disconnect();
|
||
if (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);
|
||
};
|
||
|
||
(async function main() {
|
||
console.log('[skills-edit.html] Starting initialization...');
|
||
try {
|
||
initHamburgerMenu();
|
||
|
||
const divSvgHam = document.getElementById('divSvgHam');
|
||
if (divSvgHam) divSvgHam.addEventListener('click', toggleNav);
|
||
|
||
const themeToggleButton = document.getElementById('themeToggleButton');
|
||
const logoutButton = document.getElementById('logoutButton');
|
||
|
||
if (themeToggleButton) {
|
||
themeToggleButton.addEventListener('click', () => {
|
||
isDarkMode = !isDarkMode;
|
||
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 {
|
||
if (!isAuthenticated) {
|
||
await promptLoginIfNeeded();
|
||
return;
|
||
}
|
||
await Logout();
|
||
} catch (error) {
|
||
console.error('[skills-edit.html] logout/login failed', error);
|
||
}
|
||
});
|
||
}
|
||
|
||
authMode = resolveAuthModeFromUrl();
|
||
await initializeAuthentication(authMode);
|
||
await initializeAuthenticatedPageFeatures();
|
||
|
||
|
||
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
||
divFooterCenter.textContent = 'Public mode';
|
||
divFooterRight.textContent = 'Sign in from side menu for private features';
|
||
}
|
||
|
||
await updateVersionDisplay();
|
||
} catch (error) {
|
||
console.error('[skills-edit.html] Initialization failed:', error);
|
||
divBody.innerHTML = `<div style="text-align:center;padding:50px;">
|
||
<div style="font-size:24px;margin-bottom:20px;color:red;">❌ Initialization Error</div>
|
||
<div style="font-size:16px;color:#666;">${escapeHtml(error?.message || error)}</div>
|
||
<div style="margin-top:20px;"><button onclick="location.reload()" style="padding:10px 20px; font-size:16px;">Retry</button></div>
|
||
</div>`;
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|