Files
sovereign_browser/www/agents/chat.js
T

999 lines
37 KiB
JavaScript

/* Agent Chat — sovereign://agents/chat
*
* Two-pane layout: conversations + skills (left), chat thread (right).
*
* Uses the shared dot-menu and post-composer libraries (loaded as regular
* <script> tags in chat.html, attached to window). WebKit's custom
* sovereign:// scheme does not support ES module imports, so we use
* plain scripts with global scope instead of type="module".
*
* Bug fixes applied:
* - renderMessage() includes data-msg-index on .msg-bubble-content and
* .msg-bubble-menu-host so renderBubbleContent()/mountDotMenu() find them.
* - myPubkey is fetched at runtime from sovereign://nostr/getPublicKey
* instead of being injected via g_strdup_printf().
* - New Chat saves the empty conversation to Nostr immediately (server
* side in handle_agents_conversations_new), so it appears in the list.
* - renderMarkdown() sanitizes marked.js output with DOMPurify.
*/
var lastCount = -1;
var currentConvId = null;
var lastState = 'idle';
var saveTimer = null;
var allSkills = [];
var selectedSkills = [];
var defaultSkill = null;
var myPubkey = '';
var composerApi = null;
var skillEditorValues = {};
/* fetch() does not work with the sovereign:// custom scheme in
* WebKit — use XMLHttpRequest instead. Returns a Promise that
* resolves to the parsed JSON response. */
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try {
var d = JSON.parse(x.responseText);
resolve(d);
} catch (e) {
reject(e);
}
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
/* ── Tab switching ─────────────────────────────────────────── */
/* switchTab(name) hides all .tab-content panes, shows #tab-{name},
* and updates the .tab.active class on the tab bar buttons. */
function switchTab(name) {
var tabs = document.querySelectorAll('.tab');
var contents = document.querySelectorAll('.tab-content');
tabs.forEach(function(t) {
if (t.getAttribute('data-tab') === name) {
t.classList.add('active');
} else {
t.classList.remove('active');
}
});
contents.forEach(function(c) {
if (c.id === 'tab-' + name) {
c.classList.add('active');
} else {
c.classList.remove('active');
}
});
/* When switching to the chat tab, scroll to the latest message. */
if (name === 'chat') {
var c = document.getElementById('messages');
if (c) c.scrollTop = c.scrollHeight;
}
}
/* ── Conversation list (left pane) ─────────────────────────── */
function formatTimestamp(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
if (isNaN(d.getTime())) return '';
var now = new Date();
var sameDay = d.toDateString() === now.toDateString();
if (sameDay) {
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}
return d.toLocaleDateString([], {month: 'short', day: 'numeric'});
}
function renderConvList(convs) {
var c = document.getElementById('conv-list');
var html = '';
if (!convs || convs.length === 0) {
html = '<div class="conv-empty">No saved conversations</div>';
c.innerHTML = html;
updateComposerEnabled();
return;
}
convs.forEach(function(cv) {
/* Use strict string comparison so only ONE item is ever active,
* even if ids arrive as different types from different sources. */
var active = (String(cv.id) === String(currentConvId)) ? ' conv-active' : '';
html += '<div class="conv-item' + active + '" ';
html += 'onclick="loadConv(\'' + esc(cv.id) + '\')">';
html += '<div class="conv-item-content">';
html += '<span class="conv-title">' + esc(cv.title || '(untitled)') + '</span>';
if (cv.created || cv.updated || cv.updated_at) {
html += '<span class="conv-timestamp">' +
esc(formatTimestamp(cv.updated || cv.updated_at || cv.created)) +
'</span>';
}
html += '</div>';
/* Rename button (✎) — appears on hover like the delete button. */
html += '<button class="conv-rename btn" title="Rename" ';
html += 'onclick="event.stopPropagation();renameConv(\'' +
esc(cv.id) + '\',\'' + esc(cv.title || '').replace(/'/g, "\\'") + '\')">&#9998;</button>';
html += '<button class="conv-del btn btn-del" title="Delete" ';
html += 'onclick="event.stopPropagation();deleteConv(\'' +
esc(cv.id) + '\')">x</button>';
html += '</div>';
});
c.innerHTML = html;
updateComposerEnabled();
}
function fetchConvList() {
sovereignGet('sovereign://agents/conversations?_=' + Date.now())
.then(function(convs) {
renderConvList(convs);
/* If no conversation is selected and there are conversations
* available, auto-select the first (most recent) one so the
* chat card matches the displayed messages. This ensures there
* is always a selected chat card on page load. */
if ((currentConvId === null || currentConvId === undefined ||
String(currentConvId) === '') && convs && convs.length > 0) {
loadConv(convs[0].id);
}
})
.catch(function(e) { console.error('fetchConvList:', e); });
}
function newChat() {
/* The ONLY place a new conversation is created. No other code path
* (page load, send, delete) calls this — the user must click the
* "+ New Chat" button. Prompt for a name first; if the user cancels
* the prompt, do nothing. */
var name = prompt('Name for new chat:', 'New Chat');
if (name === null) return; /* user cancelled */
name = name.trim();
if (!name) name = 'New Chat';
sovereignGet('sovereign://agents/conversations/new?title=' +
encodeURIComponent(name) + '&_=' + Date.now())
.then(function(d) {
if (d.error) {
console.error('newChat error:', d.message);
return;
}
/* The server saves the empty conversation to Nostr immediately,
* so set currentConvId from the response and refresh the list. */
currentConvId = d.id || null;
lastCount = -1;
document.getElementById('messages').innerHTML = '';
clearStatusMessage();
fetchConvList();
fetchMessages();
/* Switch to the chat tab so the user sees the new conversation. */
switchTab('chat');
})
.catch(function(e) { console.error('newChat:', e); });
}
function loadConv(id) {
sovereignGet('sovereign://agents/conversations/load?id=' +
encodeURIComponent(id) + '&_=' + Date.now())
.then(function(d) {
if (d.error) {
console.error('loadConv error:', d.message);
return;
}
currentConvId = id;
lastCount = -1;
clearStatusMessage();
fetchMessages();
fetchConvList();
/* Switch to the chat tab so the user sees the loaded conversation. */
switchTab('chat');
})
.catch(function(e) { console.error('loadConv:', e); });
}
function deleteConv(id) {
if (!confirm('Delete this conversation?')) return;
sovereignGet('sovereign://agents/conversations/delete?id=' +
encodeURIComponent(id) + '&_=' + Date.now())
.then(function(d) {
if (String(currentConvId) === String(id)) {
/* Deselect — do NOT auto-create a replacement chat. The user
* can click "+ New Chat" or pick another conversation. */
currentConvId = null;
lastCount = -1;
document.getElementById('messages').innerHTML = '';
clearStatusMessage();
}
fetchConvList();
fetchMessages();
})
.catch(function(e) { console.error('deleteConv:', e); });
}
/* Rename a conversation. Prompts for a new title, then calls the
* sovereign://agents/conversations/rename endpoint which updates the
* title in the local SQLite cache and re-publishes the kind 30078
* event with the new title. */
function renameConv(id, currentTitle) {
var newTitle = prompt('Rename conversation:', currentTitle || '');
if (newTitle === null) return; /* user cancelled */
newTitle = newTitle.trim();
if (!newTitle) {
alert('Title cannot be empty.');
return;
}
sovereignGet('sovereign://agents/conversations/rename?id=' +
encodeURIComponent(id) + '&title=' +
encodeURIComponent(newTitle) + '&_=' + Date.now())
.then(function(d) {
if (d.error) {
alert('Rename failed: ' + (d.message || 'unknown error'));
return;
}
fetchConvList();
})
.catch(function(e) {
console.error('renameConv:', e);
alert('Rename failed: ' + e);
});
}
/* ── Auto-save (debounced) after agent response completes ──── */
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(function() {
saveTimer = null;
doSave();
}, 800);
}
function doSave() {
/* Only derive a title for NEW conversations (when currentConvId is
* null). For existing conversations, don't pass a title — the C-side
* agent_conversations_save() will preserve the existing title (which
* may have been set by the user via rename). This prevents the
* auto-save from overwriting a renamed title. */
var title = '';
if (!currentConvId) {
var firstUser = document.querySelector('.msg-bubble-row.outgoing .msg-bubble-content');
if (firstUser) {
title = firstUser.textContent.substring(0, 60).replace(/\n/g, ' ');
}
}
var url = 'sovereign://agents/conversations/save?_=' + Date.now();
if (currentConvId) {
url += '&id=' + encodeURIComponent(currentConvId);
}
if (title) {
url += '&title=' + encodeURIComponent(title);
}
sovereignGet(url)
.then(function(d) {
if (d.status === 'saved' && d.id) {
currentConvId = d.id;
fetchConvList();
}
})
.catch(function(e) { console.error('doSave:', e); });
}
/* ── Markdown renderer (uses marked.js + DOMPurify) ────────── */
/* The full marked.js library is loaded via <script> in chat.html.
* This gives us GFM tables, fenced code blocks, syntax highlighting
* hooks, nested lists, blockquotes, etc. — far more robust than the
* previous hand-rolled regex renderer.
* DOMPurify (purify.min.js) sanitizes the marked output to prevent
* XSS from any model-generated content.
* Falls back to escaped text if marked is not loaded. */
function renderMarkdown(text) {
if (!text) return '';
if (typeof marked !== 'undefined' && marked.parse) {
var raw = marked.parse(text, { gfm: true, breaks: true });
if (typeof DOMPurify !== 'undefined' && DOMPurify.sanitize) {
return DOMPurify.sanitize(raw);
}
return raw;
}
/* Fallback: escape and wrap in <pre> if marked isn't loaded. */
return '<pre>' + esc(text) + '</pre>';
}
/* ── View mode tracking + bubble content rendering ─────────── */
var viewModes = {};
function formatJsonForDisplay(text) {
try {
var obj = JSON.parse(text);
return JSON.stringify(obj, null, 2);
} catch (e) { return null; }
}
function renderBubbleContent(contentEl, m, index) {
var mode = viewModes[index] || 'markdown';
var text = m.content || '';
if (mode === 'raw') {
contentEl.innerHTML = '';
contentEl.style.whiteSpace = 'pre-wrap';
contentEl.textContent = text;
} else if (mode === 'json') {
var formatted = formatJsonForDisplay(text);
contentEl.innerHTML = '';
contentEl.style.whiteSpace = 'pre-wrap';
contentEl.textContent = formatted || text;
} else {
contentEl.style.whiteSpace = '';
contentEl.innerHTML = renderMarkdown(text);
}
}
/* ── Chat thread (right pane) ──────────────────────────────── */
function renderMessage(m, index) {
var role = m.role || 'unknown';
var isOutgoing = (role === 'user');
var dirClass = isOutgoing ? 'outgoing' : 'incoming';
var senderName = 'You';
if (role === 'assistant') senderName = 'Assistant';
else if (role === 'tool') senderName = 'Tool';
else if (role === 'system') senderName = 'System';
else if (!isOutgoing)
senderName = role.charAt(0).toUpperCase() + role.slice(1);
var html = '<div class="msg-bubble-row ' + dirClass + '">';
html += '<div class="msg-bubble ' + dirClass + '">';
html += '<div class="msg-bubble-header">';
html += '<div class="msg-bubble-header-main">';
html += '<div class="msg-bubble-sender-meta">';
html += '<div class="msg-bubble-sender-name">' + esc(senderName) + '</div>';
html += '</div></div>';
/* Bug A fix: data-msg-index is required on both the menu host and the
* content element so renderBubbleContent() and mountDotMenu() can locate
* them via querySelector after innerHTML is set. */
html += '<div class="msg-bubble-menu-host" data-msg-index="' + index + '"></div>';
html += '</div>';
html += '<div class="msg-bubble-content" data-msg-index="' + index + '"></div>';
/* Tool calls and tool_call_id are not displayed in the chat — they
* can be very large (e.g. entire web pages) and would clutter the UI.
* They're kept in the local session for the agent loop's context. */
html += '</div></div>';
return html;
}
function renderMessages(msgs) {
var c = document.getElementById('messages');
var html = '';
/* Only render user and assistant messages in the chat. Tool messages
* (tool call results) can be very large (e.g. entire web pages) and
* would clutter the UI. They're kept in the local session for the
* agent loop's context but not displayed. */
var visibleMsgs = msgs.filter(function(m) {
return m.role === 'user' || m.role === 'assistant';
});
visibleMsgs.forEach(function(m, i) { html += renderMessage(m, i); });
c.innerHTML = html;
visibleMsgs.forEach(function(m, i) {
var contentEl = c.querySelector(
'.msg-bubble-content[data-msg-index="' + i + '"]');
if (contentEl) renderBubbleContent(contentEl, m, i);
var menuHost = c.querySelector(
'.msg-bubble-menu-host[data-msg-index="' + i + '"]');
if (menuHost) {
/* The imported mountDotMenu uses the config form:
* mountDotMenu(host, { items: [...], ariaLabel: '...' })
* It returns { open, close, destroy, updateItems }. */
mountDotMenu(menuHost, {
ariaLabel: 'Message options',
items: [
{ label: 'Copy message', onClick: function() {
navigator.clipboard.writeText(m.content || '');
}},
{ label: 'View as markdown', onClick: function() {
viewModes[i] = 'markdown';
renderBubbleContent(contentEl, m, i);
}},
{ label: 'View as raw', onClick: function() {
viewModes[i] = 'raw';
renderBubbleContent(contentEl, m, i);
}},
{ label: 'View as pretty JSON', onClick: function() {
viewModes[i] = 'json';
renderBubbleContent(contentEl, m, i);
}}
]
});
}
});
c.scrollTop = c.scrollHeight;
}
function fetchMessages() {
/* Append a cache-busting query parameter. WebKit caches
* sovereign:// scheme responses, so without this every poll
* returns the first (empty) response and new messages never
* appear in the UI. */
var url = 'sovereign://agents/messages?_=' + Date.now();
sovereignGet(url)
.then(function(msgs) {
/* Always re-render. The previous guard (msgs.length !== lastCount)
* skipped re-renders when the count was unchanged, but the agent
* loop can replace the last assistant message in place (same row
* count, new content) — so we must render on every fetch. The
* render is cheap and idempotent. */
lastCount = msgs.length;
renderMessages(msgs);
})
.catch(function(e) { console.error('fetchMessages:', e); });
}
/* ── Status message (replaces the old #status-bar) ─────────── */
/* showStatusMessage() posts a temporary system-style message bubble
* below the chat thread. clearStatusMessage() hides it. Used by
* applyStatus() for "Thinking...", "Calling tool: ...", and "Error: ...".
* The bubble is italic and muted (see #status-msg in chat.css). */
function showStatusMessage(text) {
var row = document.getElementById('status-msg');
var bubble = document.getElementById('status-msg-bubble');
if (!row || !bubble) return;
bubble.textContent = text;
row.style.display = '';
/* Keep the chat scrolled to the bottom so the status message is
* visible. */
var c = document.getElementById('messages');
if (c) c.scrollTop = c.scrollHeight;
}
function clearStatusMessage() {
var row = document.getElementById('status-msg');
if (row) row.style.display = 'none';
}
/* Apply a status object to the UI. Used by both the one-shot
* initial fetch, the WebSocket push event handler, and the polling
* fallback. Posts status messages as system bubbles in the chat
* window instead of updating a status bar. */
function applyStatus(s) {
var stopBtn = document.getElementById('stop-btn');
var map = {idle:'Idle', thinking:'Thinking...',
tool_call:'Calling tool', complete:'Complete',
error:'Error', cancelled:'Cancelled'};
var label = map[s.state] || s.state;
if (s.state === 'tool_call' && s.current_tool) {
label = 'Calling tool: ' + s.current_tool +
' (iteration ' + s.iteration + ')';
}
if (s.state === 'error' && s.error) {
label = 'Error: ' + s.error;
}
/* Show a status bubble while the agent is actively working or has
* errored. Hide it when idle/complete/cancelled (the real response
* is already in the chat thread). */
if (s.state === 'thinking' || s.state === 'tool_call') {
showStatusMessage(label);
} else if (s.state === 'error') {
showStatusMessage(label);
} else {
clearStatusMessage();
}
var running = (s.state === 'thinking' || s.state === 'tool_call');
if (stopBtn) stopBtn.style.display = running ? '' : 'none';
if (composerApi && typeof composerApi.setDisabled === 'function') {
/* Disable while running OR when no chat is selected. The composer
* must never be enabled if there is no active conversation. */
var hasChat = (currentConvId !== null && currentConvId !== undefined &&
String(currentConvId) !== '');
composerApi.setDisabled(running || !hasChat);
}
/* On transition to complete/error/cancelled, fetch final
* messages and auto-save the conversation (debounced). */
if (s.state === 'complete' || s.state === 'error' ||
s.state === 'cancelled') {
fetchMessages();
if (lastState === 'thinking' || lastState === 'tool_call') {
scheduleSave();
}
}
lastState = s.state;
}
/* One-shot initial status fetch (before WebSocket connects). */
function updateStatus() {
var url = 'sovereign://agents/status?_=' + Date.now();
sovereignGet(url)
.then(function(s) { applyStatus(s); })
.catch(function(e) { console.error('updateStatus:', e); });
}
/* WebSocket for push events. The agent loop emits 'agent_status' and
* 'agent_message' events via the existing WebSocket server on port 17777.
*
* NOTE: sovereign:// is registered as a *secure* URI scheme in WebKit
* (see main.c — webkit_security_manager_register_uri_scheme_as_secure).
* That means the chat page is treated as a secure context, and WebKit
* blocks insecure ws:// connections from it as mixed content. So the
* WebSocket push may silently fail to connect. To keep the UI live
* regardless, we run a polling fallback (every 500ms) that fetches both
* status and messages. If the WebSocket does connect, it just makes the
* UI update a little faster — the poll is the source of truth. */
var ws = null;
var pollTimer = null;
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(function() {
updateStatus();
fetchMessages();
}, 500);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function connectWS() {
try {
ws = new WebSocket('ws://localhost:17777/agent');
} catch (e) {
console.error('WebSocket connect failed:', e);
startPolling();
return;
}
ws.onopen = function() {
/* WebSocket connected. We keep polling anyway because WebKit may
* block ws:// as mixed content on the secure sovereign:// page —
* the socket can "open" but never deliver messages. Polling is
* the reliable mechanism; the WebSocket is a bonus. */
};
ws.onmessage = function(ev) {
try {
var d = JSON.parse(ev.data);
if (d.type === 'event' && d.event === 'agent_status' && d.data) {
applyStatus(d.data);
} else if (d.type === 'event' && d.event === 'agent_message') {
fetchMessages();
}
} catch (e) { /* ignore parse errors */ }
};
ws.onclose = function() {
/* Reconnect after 2s if the socket drops, and resume polling in
* the meantime so the UI stays live. */
startPolling();
setTimeout(function() { connectWS(); }, 2000);
};
ws.onerror = function() { /* onclose will handle reconnect */ };
/* Start polling immediately; it stops once the WebSocket opens. */
startPolling();
}
/* A chat must be selected before the user can send messages. This is
* called whenever the conversation list is re-rendered or a chat is
* loaded/deselected. It disables the composer when no chat is active
* and toggles the "no chat" placeholder in the chat pane. */
function updateComposerEnabled() {
var hasChat = (currentConvId !== null && currentConvId !== undefined &&
String(currentConvId) !== '');
if (composerApi && typeof composerApi.setDisabled === 'function') {
/* Only force-disable here when there is no chat. applyStatus() also
* toggles disabled based on agent running state. */
if (!hasChat) composerApi.setDisabled(true);
}
var placeholder = document.getElementById('no-chat-placeholder');
if (placeholder) {
placeholder.style.display = hasChat ? 'none' : '';
}
}
function sendMessage(text) {
/* A chat must be selected before sending. If none is selected, show
* a status message and bail out — do NOT auto-create a new chat. */
if (currentConvId === null || currentConvId === undefined ||
String(currentConvId) === '') {
showStatusMessage('Select or create a chat first');
return;
}
/* Support both the legacy textarea call (no args, reads #msg-input)
* and the composer call (text passed in directly). */
if (text === undefined) {
var input = document.getElementById('msg-input');
if (input) {
text = input.value.trim();
input.value = '';
}
}
text = String(text || '').trim();
if (!text) return;
sovereignGet('sovereign://agents/send?text=' + encodeURIComponent(text))
.then(function(d) {
if (d.error) {
showStatusMessage('Error: ' + d.message);
} else {
fetchMessages();
}
})
.catch(function(e) { console.error('sendMessage:', e); });
}
function cancelAgent() {
sovereignGet('sovereign://agents/cancel')
.then(function(d) { updateStatus(); })
.catch(function(e) { console.error('cancelAgent:', e); });
}
/* ── Composer (input area) ─────────────────────────────────── */
/* Replaces the hand-rolled textarea with the shared post-composer
* library. The composer provides auto-resize, a send button, and
* input history. We disable image uploads (showUploadIcon:false)
* and the preview pane (showPreview:false) since sovereign_browser
* chat is plain text only. */
function mountChatComposer() {
var host = document.getElementById('composer-host');
if (!host) return;
/* layout:'inline' puts the editor shell and send button in a single
* flex row (.post-composer-inline-row) so the Send button sits to the
* RIGHT of the text entry instead of below it. */
composerApi = mountComposer(host, {
disabled: false,
showUploadIcon: false,
showPreview: false,
submitOnEnter: false,
alwaysShowSendButton: true,
layout: 'inline',
onSubmit: async function(text) {
sendMessage(text);
}
});
}
/* ── Skills (left pane, bottom section) ───────────────────── */
function fetchSkills() {
sovereignGet('sovereign://agents/skills?_=' + Date.now())
.then(function(skills) {
allSkills = skills || [];
renderSkillList();
})
.catch(function(e) { console.error('fetchSkills:', e); });
}
function fetchDefaultSkill() {
sovereignGet('sovereign://agents/skills/default?_=' + Date.now())
.then(function(sk) {
defaultSkill = sk || null;
renderSkillList();
})
.catch(function(e) { console.error('fetchDefaultSkill:', e); });
}
function fetchSelectedSkills() {
sovereignGet('sovereign://agents/skills/selected?_=' + Date.now())
.then(function(arr) {
selectedSkills = arr || [];
renderSkillList();
})
.catch(function(e) { console.error('fetchSelectedSkills:', e); });
}
function isSkillSelected(d) {
return selectedSkills.indexOf(d) >= 0;
}
/* The Sovereign Browser Skill is always selected by default — it's
* the base system prompt. It has no d-tag (unsaved), so we track it
* with a special sentinel. */
var DEFAULT_SKILL_KEY = '__sovereign_default__';
function isDefaultSkillSelected() {
/* The default skill is always included in the system prompt (it's
* the base). The checkbox is cosmetic — always checked. */
return true;
}
function renderSkillList() {
var c = document.getElementById('skill-list');
if (!c) return;
var html = '';
/* Sovereign Browser Skill (unsaved) — always at the top. */
if (defaultSkill) {
html += renderSkillItem(defaultSkill, true, DEFAULT_SKILL_KEY);
}
if (!allSkills || allSkills.length === 0) {
if (!defaultSkill) {
html = '<div class="conv-empty">No skills available</div>';
}
c.innerHTML = html;
return;
}
allSkills.forEach(function(sk) {
html += renderSkillItem(sk, false, sk.d);
});
c.innerHTML = html;
}
/* Use event delegation on the skill-list container so listeners
* survive innerHTML replacements from multiple async renderSkillList()
* calls. Attach once on init. */
function initSkillListDelegation() {
var c = document.getElementById('skill-list');
if (!c) return;
c.addEventListener('input', function(e) {
var el = e.target;
if (!el || !el.matches) return;
if (!el.matches('[data-skill-key][data-field]')) return;
var key = String(el.getAttribute('data-skill-key') || '').trim();
var field = String(el.getAttribute('data-field') || '').trim();
if (!key || !field) return;
if (!skillEditorValues[key]) skillEditorValues[key] = {};
skillEditorValues[key][field] = el.value;
});
c.addEventListener('click', function(e) {
var btn = e.target;
if (!btn || !btn.matches) return;
if (!btn.matches('button[data-action="save-skill"]')) return;
var key = String(btn.getAttribute('data-skill-key') || '').trim();
saveSkillByKey(key);
});
}
function renderSkillItem(sk, isDefault, key) {
var checked = isDefault ? 'checked' :
(isSkillSelected(sk.d) ? 'checked' : '');
var tools = sk.requires_tools || [];
var hasTools = tools.length > 0;
var toolBadge = hasTools
? '<span class="skill-badge skill-badge-tools">requires: '
+ esc(tools.join(', ')) + '</span>'
: '';
var unsavedBadge = isDefault
? '<span class="skill-badge skill-badge-unsaved">unsaved</span>'
: '';
var isMine = isDefault || (sk.pubkey && myPubkey && sk.pubkey === myPubkey);
var delBtn = (!isDefault && isMine)
? '<button class="conv-del btn btn-del" onclick="deleteSkill(\''
+ esc(sk.d) + '\')">x</button>'
: '';
var edit = skillEditorValues[key] || {};
var nameVal = edit.name !== undefined ? edit.name : (sk.name || '');
var descVal = edit.description !== undefined ? edit.description : (sk.description || '');
var tmplVal = edit.template !== undefined ? edit.template : (sk.content || '');
var toolsVal = edit.requires_tools !== undefined ? edit.requires_tools :
(tools.join(', '));
var html = '<details class="skillStackedItem" data-skill-key="' + esc(key) + '">';
html += '<summary>';
html += '<input type="checkbox" class="skill-checkbox" ' + checked;
if (!isDefault) {
html += ' onchange="toggleSkill(\'' + esc(sk.d) + '\')"';
} else {
html += ' onclick="event.preventDefault();return false;"';
}
html += '>';
html += '<span class="skill-name">' + esc(sk.name) + '</span>';
html += unsavedBadge;
html += toolBadge;
html += delBtn;
html += '</summary>';
html += '<div class="aiSkillControlsRow">';
html += '<div class="aiSkillInlineField"><label>Name</label>';
html += '<input class="aiInput" data-skill-key="' + esc(key) + '" data-field="name" value="' + esc(nameVal) + '"></div>';
html += '<div class="aiSkillInlineField"><label>Description</label>';
html += '<input class="aiInput" data-skill-key="' + esc(key) + '" data-field="description" value="' + esc(descVal) + '"></div>';
html += '<div class="aiSkillInlineField"><label>Template</label>';
html += '<textarea class="taSkillTemplateEditor" data-skill-key="' + esc(key) + '" data-field="template">' + esc(tmplVal) + '</textarea></div>';
html += '<div class="aiSkillInlineField"><label>Requires tools (comma-separated)</label>';
html += '<input class="aiInput" data-skill-key="' + esc(key) + '" data-field="requires_tools" value="' + esc(toolsVal) + '"></div>';
html += '</div>';
html += '<div class="aiSkillActionsRow">';
var canSave = isMine;
html += '<button class="btn" data-action="save-skill" data-skill-key="' + esc(key) + '"' + (canSave ? '' : ' disabled') + '>' + (canSave ? (isDefault ? 'Save as Skill' : 'Save / Update Skill') : 'View Only (not your skill)') + '</button>';
html += '</div>';
html += '</details>';
return html;
}
function toggleSkill(d) {
sovereignGet('sovereign://agents/skills/select?d='
+ encodeURIComponent(d))
.then(function(d2) {
selectedSkills = d2.selected || [];
renderSkillList();
})
.catch(function(e) { console.error('toggleSkill:', e); });
}
function deleteSkill(d) {
if (!confirm('Delete this skill?')) return;
sovereignGet('sovereign://agents/skills/delete?d='
+ encodeURIComponent(d))
.then(function(d2) {
fetchSkills();
fetchSelectedSkills();
})
.catch(function(e) { console.error('deleteSkill:', e); });
}
/* Save / update a skill from the inline editor. For the default
* (unsaved) skill, this publishes it to Nostr as a new kind 31123
* event AND updates the local settings. For saved skills, it
* re-publishes with the edited content. */
function saveSkillByKey(key) {
/* Read values from the DOM inputs (which are populated from the
* skill object on render), falling back to skillEditorValues if
* the user has typed changes. */
var edit = skillEditorValues[key] || {};
var nameInput = document.querySelector(
'input[data-field=name][data-skill-key="' + key + '"]');
var descInput = document.querySelector(
'input[data-field=description][data-skill-key="' + key + '"]');
var tmplInput = document.querySelector(
'textarea[data-field=template][data-skill-key="' + key + '"]');
var toolsInput = document.querySelector(
'input[data-field=requires_tools][data-skill-key="' + key + '"]');
var name = (edit.name !== undefined ? edit.name :
(nameInput ? nameInput.value : '')).trim();
var desc = (edit.description !== undefined ? edit.description :
(descInput ? descInput.value : '')).trim();
var content = (edit.template !== undefined ? edit.template :
(tmplInput ? tmplInput.value : '')).trim();
var toolsRaw = (edit.requires_tools !== undefined ? edit.requires_tools :
(toolsInput ? toolsInput.value : '')).trim();
if (!name || !content) {
alert('Name and template are required.');
return;
}
var tools = [];
if (toolsRaw) {
tools = toolsRaw.split(',').map(function(t) {
return t.trim();
}).filter(function(t) { return t.length > 0; });
}
if (key === DEFAULT_SKILL_KEY) {
/* Save locally first, then publish to Nostr. */
var saveUrl = 'sovereign://agents/set?key=agent.skill_name&value=' + encodeURIComponent(name);
sovereignGet(saveUrl)
.then(function() {
return sovereignGet('sovereign://agents/set?key=agent.skill_description&value=' + encodeURIComponent(desc));
})
.then(function() {
return sovereignGet('sovereign://agents/set?key=agent.skill_template&value=' + encodeURIComponent(content));
})
.then(function() {
return sovereignGet('sovereign://agents/set?key=agent.skill_requires_tools&value=' + encodeURIComponent(toolsRaw));
})
.then(function() {
var url = 'sovereign://agents/skills/create?name='
+ encodeURIComponent(name)
+ '&description=' + encodeURIComponent(desc)
+ '&content=' + encodeURIComponent(content)
+ '&requires_tools=' + encodeURIComponent(JSON.stringify(tools));
return sovereignGet(url);
})
.then(function(d) {
if (d.error) {
alert('Publish failed: ' + d.message);
} else {
/* Clear editor values and refresh. */
delete skillEditorValues[key];
fetchDefaultSkill();
fetchSkills();
}
})
.catch(function(e) { console.error('saveSkillByKey (default):', e); });
} else {
/* Update an existing skill (re-publish with same d-tag). */
var url = 'sovereign://agents/skills/update?d='
+ encodeURIComponent(key)
+ '&name=' + encodeURIComponent(name)
+ '&description=' + encodeURIComponent(desc)
+ '&content=' + encodeURIComponent(content)
+ '&requires_tools=' + encodeURIComponent(JSON.stringify(tools));
sovereignGet(url)
.then(function(d) {
if (d.error) {
alert('Update failed: ' + d.message);
} else {
delete skillEditorValues[key];
fetchSkills();
}
})
.catch(function(e) { console.error('saveSkillByKey (update):', e); });
}
}
function showCreateSkillForm() {
document.getElementById('create-skill-form').style.display = '';
}
function hideCreateSkillForm() {
document.getElementById('create-skill-form').style.display = 'none';
document.getElementById('skill-name').value = '';
document.getElementById('skill-desc').value = '';
document.getElementById('skill-content').value = '';
document.getElementById('skill-tools').value = '';
}
function publishSkill() {
var name = document.getElementById('skill-name').value.trim();
var desc = document.getElementById('skill-desc').value.trim();
var content = document.getElementById('skill-content').value.trim();
var toolsRaw = document.getElementById('skill-tools').value.trim();
if (!name || !content) {
alert('Name and system prompt template are required.');
return;
}
var tools = [];
if (toolsRaw) {
tools = toolsRaw.split(',').map(function(t) {
return t.trim();
}).filter(function(t) { return t.length > 0; });
}
var url = 'sovereign://agents/skills/create?name='
+ encodeURIComponent(name)
+ '&description=' + encodeURIComponent(desc)
+ '&content=' + encodeURIComponent(content)
+ '&requires_tools=' + encodeURIComponent(JSON.stringify(tools));
sovereignGet(url)
.then(function(d) {
if (d.error) {
alert('Failed: ' + d.message);
return;
}
hideCreateSkillForm();
fetchSkills();
})
.catch(function(e) { console.error('publishSkill:', e); });
}
/* Fetch the user's pubkey at runtime (replaces the former C-side
* g_strdup_printf("%s", pubkey) injection). Used to show delete
* buttons on user-authored skills.
*
* Uses the NIP-07 window.nostr shim (installed by nostr_inject.c) which
* performs a synchronous XHR to sovereign://nostr/getPublicKey. Direct
* async XHR to sovereign://nostr/ is blocked by WebKitGTK CORS on custom
* schemes, so we go through the shim instead. */
function fetchMyPubkey() {
try {
if (window.nostr && window.nostr.getPublicKey) {
window.nostr.getPublicKey()
.then(function(pk) {
if (pk) {
myPubkey = pk;
renderSkillList();
}
})
.catch(function(e) { console.error('fetchMyPubkey:', e); });
}
} catch (e) { console.error('fetchMyPubkey:', e); }
}
/* ── Init ──────────────────────────────────────────────────── */
mountChatComposer();
initSkillListDelegation();
fetchConvList();
fetchMessages();
fetchDefaultSkill();
fetchSkills();
fetchSelectedSkills();
fetchMyPubkey();
/* Disable the composer until a conversation is selected (Issue 2).
* fetchConvList() also calls this after rendering, but we call it
* here too so the composer is disabled immediately on page load
* before the conversation list arrives. */
updateComposerEnabled();
updateStatus();
/* Connect WebSocket for push events (replaces polling). */
connectWS();