999 lines
37 KiB
JavaScript
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, "\\'") + '\')">✎</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();
|