Files
client/www/skills-edit.html

2082 lines
71 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>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>