v0.0.27 - Embedded agent: tabbed chat, sidebar mode, skills, Nostr sync, embedded web content, bug fixes

This commit is contained in:
Laan Tungir
2026-07-15 12:43:31 -04:00
parent b57367de1c
commit 5150b7282a
66 changed files with 14458 additions and 115 deletions

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@ build/
# vendored dependency (added as a subtree/checkout, not tracked here)
nostr_core_lib/
# auto-generated embedded web content (built by embed_web_files.sh)
src/embedded_web_content.c
src/embedded_web_content.h

View File

@@ -17,11 +17,24 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a
NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm
BIN := sovereign_browser
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c
$(BIN): $(SRC) $(NOSTR_LIB)
# Web files embedded into the binary as C byte arrays.
WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null)
# Default goal: build the browser binary.
$(BIN): $(SRC) $(NOSTR_LIB) src/embedded_web_content.c
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)
# Auto-generated C byte arrays from www/. Regenerated whenever a web
# file changes (or on first build). Both the .c and .h are produced by
# a single invocation of embed_web_files.sh.
src/embedded_web_content.c: $(WEB_FILES) embed_web_files.sh
./embed_web_files.sh
src/embedded_web_content.h: src/embedded_web_content.c
@true
$(NOSTR_LIB):
@echo "Building nostr_core_lib..."
cd nostr_core_lib && ./build.sh --nips=all

View File

@@ -31,6 +31,42 @@ because trust moves to the layer where it belongs: your keys.
come) can freely call any endpoint — including FIPS mesh services — without
the workarounds traditional browsers force on automators.
## Nostr interaction policy
sovereign_browser interacts with Nostr via **discrete events only** — no
continuous WebSocket subscriptions. The browser fetches events on demand
(via `relay_fetch.c`) and publishes events on demand (via `settings_sync.c`,
`agent_conversations.c`, `agent_skills.c`). There is no persistent
subscription that receives live updates.
This is a deliberate architectural choice:
- **Simplicity** — No subscription lifecycle management, no reconnection
logic, no event deduplication across reconnects.
- **Predictability** — The user controls when data is fetched or published.
No background traffic, no surprise events arriving.
- **Resource efficiency** — No open WebSocket connections consuming memory
and bandwidth while idle.
**Trade-off:** The browser does not receive live updates. If a conversation
or skill is created in another app (e.g., the client web app), it won't
appear in sovereign_browser until the user explicitly refreshes. UIs that
need fresh data provide **Refresh buttons** that trigger a one-shot fetch.
### What this means for the chat page
The `sovereign://agents/chat` page differs from `~/lt/client/www/ai.html`
in this respect:
- **ai.html** uses NDK with persistent subscriptions — conversations and
skills appear live as they're published to relays.
- **sovereign_browser** fetches conversations and skills on demand — the
user clicks a Refresh button to pull new data from relays.
Conversations and skills are still stored as the same Nostr event kinds
(30078 for conversations, 31123 for skills) with the same tags, so they're
**compatible** across projects — just not live-synced.
## Non-goals (for now)
- Agent integration, multi-window agent hosts, didactyl hosting, and the

View File

@@ -1 +1 @@
0.0.26
0.0.27

132
embed_web_files.sh Executable file
View File

@@ -0,0 +1,132 @@
#!/bin/bash
# embed_web_files.sh — Convert www/ web files into C byte arrays
#
# Scans www/**/*.html, www/**/*.css, www/**/*.js and generates
# src/embedded_web_content.c + src/embedded_web_content.h with the
# file contents as C byte arrays. The sovereign:// URI scheme handler
# looks up files by path via get_embedded_file().
#
# Adapted from ~/lt/c-relay/embed_web_files.sh.
set -e
echo "[embed] Embedding web files into C byte arrays..."
# Output directory for generated files
OUTPUT_DIR="src"
mkdir -p "$OUTPUT_DIR"
# Function to convert a file to a C byte array
file_to_c_array() {
local input_file="$1"
local array_name="$2"
local output_file="$3"
# Get file size
local file_size=$(stat -c%s "$input_file" 2>/dev/null || stat -f%z "$input_file" 2>/dev/null || echo "0")
echo "// Auto-generated from $input_file" >> "$output_file"
echo "static const unsigned char ${array_name}_data[] = {" >> "$output_file"
# Convert file to hex bytes
hexdump -v -e '1/1 "0x%02x,"' "$input_file" >> "$output_file"
echo "};" >> "$output_file"
echo "static const size_t ${array_name}_size = $file_size;" >> "$output_file"
echo "" >> "$output_file"
}
# Generate the header file
HEADER_FILE="$OUTPUT_DIR/embedded_web_content.h"
cat > "$HEADER_FILE" <<'EOF'
// Auto-generated embedded web content header
// Do not edit manually - generated by embed_web_files.sh
#ifndef EMBEDDED_WEB_CONTENT_H
#define EMBEDDED_WEB_CONTENT_H
#include <stddef.h>
typedef struct {
const char *path; /* e.g. "agents/chat.html" */
const unsigned char *data;
size_t size;
const char *mime_type; /* "text/html", "text/css", "application/javascript" */
} embedded_file_t;
/*
* Look up an embedded file by its path (e.g. "agents/chat.html").
* Returns NULL if not found.
*/
const embedded_file_t *get_embedded_file(const char *path);
#endif /* EMBEDDED_WEB_CONTENT_H */
EOF
# Generate the C file
SOURCE_FILE="$OUTPUT_DIR/embedded_web_content.c"
cat > "$SOURCE_FILE" <<'EOF'
// Auto-generated embedded web content
// Do not edit manually - generated by embed_web_files.sh
#include "embedded_web_content.h"
#include <string.h>
EOF
# Process each web file and build the lookup table
declare -a file_entries
for file in $(find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.mjs' \) | sort); do
if [ -f "$file" ]; then
# Get path relative to www/
rel_path="${file#www/}"
# Create C identifier from path (replace non-alphanumeric with _)
c_name=$(echo "$rel_path" | sed 's/[^a-zA-Z0-9]/_/g' | sed 's/^_//')
# Determine content type
case "$file" in
*.html) content_type="text/html" ;;
*.css) content_type="text/css" ;;
*.js) content_type="text/javascript" ;;
*.mjs) content_type="text/javascript" ;;
*) content_type="text/plain" ;;
esac
echo "[embed] $rel_path -> ${c_name} ($content_type)"
# Generate the byte array
file_to_c_array "$file" "$c_name" "$SOURCE_FILE"
# Remember for the lookup table (no outer braces — the echo loop
# below wraps each entry in a single set of braces)
file_entries+=("\"$rel_path\", ${c_name}_data, ${c_name}_size, \"$content_type\"")
fi
done
# Generate the lookup table
echo "// File mapping" >> "$SOURCE_FILE"
echo "static const embedded_file_t embedded_files[] = {" >> "$SOURCE_FILE"
for entry in "${file_entries[@]}"; do
echo " { $entry }," >> "$SOURCE_FILE"
done
echo " {NULL, NULL, 0, NULL} // Sentinel" >> "$SOURCE_FILE"
echo "};" >> "$SOURCE_FILE"
echo "" >> "$SOURCE_FILE"
# Generate the lookup function
cat >> "$SOURCE_FILE" <<'EOF'
const embedded_file_t *get_embedded_file(const char *path) {
if (!path) return NULL;
for (int i = 0; embedded_files[i].path != NULL; i++) {
if (strcmp(path, embedded_files[i].path) == 0) {
return &embedded_files[i];
}
}
return NULL;
}
EOF
echo "[embed] Generated $HEADER_FILE and $SOURCE_FILE"
echo "[embed] Embedded ${#file_entries[@]} files"

View File

@@ -0,0 +1,176 @@
# Chat Page — Discrete Event Model
## Goal
Redesign the `sovereign://agents/chat` page to work with sovereign_browser's
**discrete event** Nostr interaction model (no live subscriptions). The page
needs explicit Save/Refresh buttons for conversations and skills, matching
how the rest of the browser works.
## What's wrong now
The current chat page tries to mimic ai.html's live-subscription model:
- It auto-saves conversations after each agent response (via a debounced
`scheduleSave()`)
- It fetches the conversation list on page load but has no way to refresh
- It fetches skills on page load but has no way to refresh
- New Chat saves an empty conversation immediately (good), but there's no
explicit save button for renaming or manual saves
The user wants explicit control: **Save** to publish, **Refresh** to fetch.
## Design
### Conversation list (left pane, top section)
```
┌─────────────────────────────┐
│ [+ New Chat] [↻ Refresh] │
├─────────────────────────────┤
│ > Capital of France [×] │ ← selected, click to load
│ EU population [×] │
│ Hello world [×] │
└─────────────────────────────┘
```
- **+ New Chat** — Creates a new local session (does NOT save to Nostr yet).
The conversation is "unsaved" until the user sends a message or clicks Save.
- **↻ Refresh** — Fetches the conversation list from Nostr (one-shot relay
query via `relay_fetch.c`) and updates the list. Shows a "Refreshing..."
state while fetching.
- **Click a conversation** — Loads it from the local SQLite cache (fast, no
relay fetch). If the conversation isn't in the cache, fetch it from Nostr.
- **[×] delete button** — Deletes the conversation (publishes kind 5
tombstone, removes from local cache).
### Chat thread (right pane)
```
┌─────────────────────────────────────────────┐
│ Capital of France [✏ Rename] [💾] │ ← conversation header
├─────────────────────────────────────────────┤
│ You: What is the capital of France? │
│ Assistant: The capital of France is Paris. │
│ │
│ [⋯] │ ← dot-menu on each bubble
├─────────────────────────────────────────────┤
│ [Type a message... ] [Send] │
└─────────────────────────────────────────────┘
```
- **✏ Rename** — Inline edit the conversation title. Saves to Nostr on Enter
or blur.
- **💾 Save** — Explicitly saves the conversation to Nostr (publishes kind 30078
with the current messages + title). Shows "Saved!" feedback.
- **Auto-save is removed** — The user must click Save to persist. The only
exception: the first message exchange auto-saves once (to create the Nostr
event), so the conversation appears in the list on other devices. After
that, explicit Save only.
### Skills list (left pane, bottom section)
```
┌─────────────────────────────┐
│ Skills [↻ Refresh] │
├─────────────────────────────┤
│ [✓] Web scraper [requires: │
│ browser,fs] [×] │
│ [ ] Code reviewer [requires: │
│ shell] [×] │
│ [+ Create Skill] │
└─────────────────────────────┘
```
- **↻ Refresh** — Fetches skills from Nostr (one-shot relay query) and
updates the list.
- **Checkbox** — Toggle skill selection (saved locally, not to Nostr).
- **[×] delete** — Deletes the skill (kind 5 tombstone) — only for
user-authored skills.
- **+ Create Skill** — Opens the create form. "Publish" button saves to
Nostr.
### New endpoints needed
- **`sovereign://agents/conversations/refresh`** (GET) — Triggers a one-shot
relay fetch for kind 30078 events with `t:client-ai-chat-v1` by the user's
pubkey. Stores results in SQLite. Returns `{"status":"refreshed","count":N}`
when done. This is a blocking call (waits for relay response or timeout).
- **`sovereign://agents/skills/refresh`** (GET) — Triggers a one-shot relay
fetch for kind 31123 events. Stores in SQLite. Returns
`{"status":"refreshed","count":N}`.
- **`sovereign://agents/conversations/rename`** (GET with `?id=...&title=...`)
— Renames a conversation. Updates the local SQLite cache and re-publishes
the kind 30078 event with the new title. Returns `{"status":"renamed"}`.
### Changes to existing endpoints
- **`sovereign://agents/conversations/new`** — Should NOT save to Nostr
immediately. Just create a local session. The conversation is saved to
Nostr on the first explicit Save (or after the first message exchange).
- **`sovereign://agents/conversations/save`** — Keep as-is (explicit save).
Remove the auto-save call from `applyStatus()` in chat.js.
### Changes to `www/agents/chat.js`
1. **Remove `scheduleSave()` / `doSave()` auto-save** — Delete the debounced
auto-save logic. The user clicks Save explicitly.
2. **Add Refresh button handler**`refreshConvList()` calls
`sovereign://agents/conversations/refresh`, then re-fetches the list.
3. **Add Rename button handler**`renameConv(id)` prompts for a new title,
calls `sovereign://agents/conversations/rename?id=...&title=...`.
4. **Add Save button handler**`saveConv()` calls
`sovereign://agents/conversations/save?title=...` explicitly.
5. **Add Skills Refresh button handler**`refreshSkills()` calls
`sovereign://agents/skills/refresh`, then re-fetches the skills list.
6. **First-message auto-save** — After the first agent response in a new
conversation, auto-save once (so the conversation exists on Nostr). After
that, no auto-save.
### Changes to `src/nostr_bridge.c`
1. **Add `handle_agents_conversations_refresh()`** — Calls a new
`agent_conversations_refresh()` function that does a one-shot relay fetch.
2. **Add `handle_agents_skills_refresh()`** — Calls a new
`agent_skills_refresh()` function that does a one-shot relay fetch.
3. **Add `handle_agents_conversations_rename()`** — Calls
`agent_conversations_rename(id, title)`.
4. **Update `handle_agents_conversations_new()`** — Remove the immediate
`agent_conversations_save()` call. Just create the local session.
### Changes to `src/agent_conversations.c` / `src/agent_skills.c`
1. **Add `agent_conversations_refresh()`** — One-shot relay fetch for kind
30078 `t:client-ai-chat-v1` events by the user's pubkey. Store in SQLite.
This reuses the relay fetch logic from `relay_fetch.c` but with a specific
filter.
2. **Add `agent_conversations_rename(id, title)`** — Fetch the existing event,
decrypt, update the title, re-encrypt, re-publish.
3. **Add `agent_skills_refresh()`** — One-shot relay fetch for kind 31123
events. Store in SQLite.
### Changes to `www/agents/chat.html`
Add the Refresh, Rename, and Save buttons to the HTML structure.
## Phasing
**Phase 1:** Add Refresh buttons (conversations + skills) and the refresh
endpoints. Remove auto-save. Add explicit Save button.
**Phase 2:** Add Rename button and endpoint.
**Phase 3:** First-message auto-save (so new conversations appear on Nostr
after the first exchange, but subsequent saves are explicit).

View File

@@ -0,0 +1,174 @@
# Chat Sidebar + Tabbed Layout
## Goal
Redesign the agent chat UI for two use cases:
1. **Tabbed chat page** — Split the current cluttered `sovereign://agents/chat`
page into 3 tabs: Chat, Conversations, Skills.
2. **Sidebar mode** — Show the chat in a narrow left-hand sidebar alongside
a web page on the right, so the user can chat about the page they're
viewing.
## Current state
The `sovereign://agents/chat` page has a two-pane layout:
- Left pane: conversation list + skills list (cluttered)
- Right pane: chat messages + input
This is too much for a sidebar. The user wants tabs to organize it.
## Design
### Tabbed chat page
```
┌─────────────────────────────────────────────────┐
│ [Chat] [Conversations] [Skills] │ ← tab bar
├─────────────────────────────────────────────────┤
│ │
│ (active tab content) │
│ │
└─────────────────────────────────────────────────┘
```
**Tab 1: Chat** (default)
- Chat messages (scrollable)
- Status messages (system bubbles)
- Input area (composer + send + stop)
**Tab 2: Conversations**
- "+ New Chat" button
- "↻ Refresh" button
- Conversation list (click to load, rename, delete)
**Tab 3: Skills**
- "↻ Refresh" button
- "+ Create Skill" button
- Skills list (checkboxes, edit, delete)
- Skill editor (inline, expandable)
When a conversation is selected in Tab 2, switch to Tab 1 automatically.
When a skill is toggled in Tab 3, stay on Tab 3.
### Sidebar mode
The sidebar is a **narrow version** of the chat page, shown alongside a
web page. There are two approaches:
**Option A — Split view in the same tab:** The browser window splits into
two webviews: a narrow left webview showing `sovereign://agents/chat` (in
sidebar mode), and a right webview showing the web page. This requires
GTK-level changes to `tab_manager.c` to support split views.
**Option B — Separate sidebar panel:** A GTK panel (like a GtkPaned or
GtkBox) on the left side of the browser window, showing the chat UI
natively (not as a webview). This is more work but gives a native feel.
**Option C — Pop-out sidebar tab:** The chat page detects when it's loaded
in a narrow viewport and switches to a compact sidebar layout (tabs become
icons, messages take full width). This is the simplest — no GTK changes,
just CSS responsive design.
I recommend **Option A** (split view) for the first implementation — it
reuses the existing chat page in a narrow webview, and the tabbed layout
makes it work well in a narrow space.
### Split view implementation (Option A)
```
┌──────────────────────────────────────────────────────────┐
│ [tab strip] │
├─────────────────┬────────────────────────────────────────┤
│ [Chat][Conv] │ │
│ [Skills] │ │
│ │ Web page (right webview) │
│ Chat messages │ │
│ ... │ │
│ │ │
│ [input area] │ │
└─────────────────┴────────────────────────────────────────┘
← sidebar (300px) ← web page (flex: 1)
```
The sidebar is a second webview in the same tab, showing
`sovereign://agents/chat` in a compact layout. The user can toggle the
sidebar on/off via a menu item or keyboard shortcut.
## Implementation
### Phase 1 — Tabbed chat page
1. **`www/agents/chat.html`** — Restructure into 3 tabs:
```html
<div id="tab-bar">
<button class="tab active" data-tab="chat">Chat</button>
<button class="tab" data-tab="conversations">Conversations</button>
<button class="tab" data-tab="skills">Skills</button>
</div>
<div id="tab-chat" class="tab-content active">
<!-- messages + status + input -->
</div>
<div id="tab-conversations" class="tab-content">
<!-- new chat, refresh, conversation list -->
</div>
<div id="tab-skills" class="tab-content">
<!-- refresh, create skill, skills list -->
</div>
```
2. **`www/agents/chat.css`** — Tab bar styling (horizontal buttons, active
state), tab content (only active tab visible).
3. **`www/agents/chat.js`** — Tab switching logic:
- `switchTab(name)` — hides all tab contents, shows the selected one,
updates the active class on the tab buttons.
- `loadConv()` — after loading a conversation, switch to the "chat" tab.
- `newChat()` — after creating a new chat, switch to the "chat" tab.
### Phase 2 — Sidebar mode (split view)
1. **`src/tab_manager.c`** — Add a sidebar webview to each tab:
- Each `tab_info_t` gets a `sidebar_webview` field.
- The tab's container becomes a `GtkPaned` (horizontal) with the sidebar
on the left and the main webview on the right.
- The sidebar webview loads `sovereign://agents/chat` when shown.
- A menu item "Toggle Agent Sidebar" (or keyboard shortcut) shows/hides
the sidebar.
2. **`src/tab_manager.h`** — Add `sidebar_webview` to `tab_info_t`.
3. **`www/agents/chat.css`** — Responsive layout: when the viewport is
narrow (< 400px), switch to a compact layout (tab bar becomes icons,
messages take full width, smaller fonts).
4. **`src/main.c`** — Add a menu item "Toggle Agent Sidebar" and keyboard
shortcut (e.g., Ctrl+Shift+A).
### Phase 3 — Sidebar-aware behavior
1. **Agent tools target the main webview** — When the agent calls browser
tools (snapshot, click, eval), they should operate on the **main
webview** (the web page), not the sidebar webview (the chat page).
Update `agent_tools.c` to use the main webview, not the active webview.
2. **URL bar `;` shortcut** — When the user types `; <message>` in the URL
bar, if the sidebar is open, send the message to the sidebar's chat. If
not, open the sidebar and send the message.
## Files to modify
| File | Change |
|------|--------|
| `www/agents/chat.html` | Restructure into 3 tabs |
| `www/agents/chat.css` | Tab bar styling, responsive sidebar layout |
| `www/agents/chat.js` | Tab switching logic, sidebar-aware behavior |
| `src/tab_manager.c` | Add sidebar webview, GtkPaned split, toggle |
| `src/tab_manager.h` | Add sidebar_webview to tab_info_t |
| `src/main.c` | Add "Toggle Agent Sidebar" menu item + shortcut |
| `src/agent_tools.c` | Browser tools target main webview, not sidebar |
## Phasing
**Phase 1:** Tabbed chat page (HTML/CSS/JS only, no C changes).
**Phase 2:** Sidebar mode (split view, GTK changes).
**Phase 3:** Sidebar-aware behavior (agent tools target main webview).

View File

@@ -0,0 +1,353 @@
# Cross-Project Agent Sync — sovereign_browser ↔ client ↔ didactyl
## Goal
Align sovereign_browser's embedded agent with the patterns used in `~/lt/client`
(the web app) and `~/lt/didactyl` (the C agent daemon), so all three projects
share LLM provider settings, skills, and conversations via Nostr kind 30078
events. Also fix the immediate bug: messages don't appear in the chat UI.
## Three workstreams
```
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ BugFix │ │ SettingsSync │ │ ChatUI │
│ Fix chat UI │────▶│ kind 30078 │────▶│ Port ai.html │
│ message render │ │ d:user-settings │ │ chat layout │
│ │ │ global_llm sync │ │ + Nostr persist │
└─────────────────┘ └──────────────────┘ └──────────────────┘
```
---
## Workstream 1 — Fix chat UI message rendering (immediate bug)
**Problem:** The user sends "hello", sees the response in the console log, but
nothing appears in the chat box — neither the user message nor the response.
**Root cause:** The chat UI's `fetchMessages()` calls
`sovereign://agents/messages` which returns the message history as JSON. The
`renderMessages()` function renders them. But the polling loop calls
`updateStatus()` and `fetchMessages()` every 500ms. The status polling works
(state transitions to complete), but `fetchMessages()` either:
1. Fails silently (XHR error on `sovereign://agents/messages`), or
2. The message count check `msgs.length !== lastCount` never triggers because
the messages endpoint returns an empty array or the wrong format.
**Fix approach:**
- Add console.error logging to `fetchMessages()` to see the actual XHR response.
- Verify `sovereign://agents/messages` returns the correct JSON array format.
- Check that `agent_chat_store_get_messages()` returns data in the format the
UI expects (`[{role, content, tool_calls, tool_call_id}, ...]`).
- The `renderMessage()` function expects `m.role`, `m.content`, `m.tool_calls`
as fields — verify the store returns these.
**Files to modify:**
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_chat_page()` JS,
`handle_agents_messages()` endpoint.
---
## Workstream 2 — Kind 30078 settings sync (shared LLM config)
### What the client does
The client stores all user settings in a single kind 30078 event with
`d:user-settings`, NIP-44 self-encrypted. The schema (from
[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:140)):
```json
{
"v": 2,
"updatedAt": 1708646400,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7,
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
}
],
"favorites": ["claude-opus-4.6"]
}
}
```
### What didactyl does
Didactyl (C agent daemon) stores the same `global_llm` shape in its own
pubkey's `d:user-settings` event. See
[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:357).
### What sovereign_browser should do — Option A: Merge into d:user-settings
sovereign_browser already has kind 30078 settings sync via
[`settings_sync.c`](src/settings_sync.c), but it currently uses d-tag
`"sovereign_browser"`. We will **migrate to the shared `d:user-settings`**
event, putting browser-specific settings under a `sovereign_browser` namespace
inside the same encrypted JSON. This aligns with the client and didactyl, and
preserves privacy (no app-specific d-tag leaking which app the user runs).
**Target `d:user-settings` shape — `global` vs per-app:**
The `global` namespace holds **all shared settings** — things that should be
the same across every app the user runs. This includes LLM provider
infrastructure (under `global.agent`), and will grow to include zaps, UI
preferences, relay lists, experimental flags, etc. Each app then has its own
top-level namespace for app-specific config.
```json
{
"v": 2,
"updatedAt": 1708646400,
"global": {
"agent": {
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["gpt-4o-mini", "claude-haiku-4.5", "claude-opus-4.6"]
},
{
"name": "ollama-local",
"base_url": "http://localhost:11434",
"api_key": "",
"models": ["llama3.1", "qwen2.5"]
}
],
"favorites": ["gpt-4o-mini", "claude-opus-4.6"]
},
"zaps": {
"defaultAmountSats": 21,
"defaultComment": "",
"preferredMethod": "auto"
},
"ui": {
"theme": "dark",
"language": "en"
},
"relays": { },
"experimental": { }
},
"sovereign_browser": {
"agent": {
"provider": "ppq",
"model": "gpt-4o-mini",
"system_prompt": "",
"max_iterations": 100
},
"new_tab_url": "",
"tab_bar_position": 0,
"max_tabs": 50,
"bootstrap_relays": "...",
"search_engine": "duckduckgo",
"theme_dark": false,
"shortcuts": { ... }
},
"client": {
"ai": {
"provider": "ppq",
"model": "claude-opus-4.6",
"routstr_balance": 0
}
},
"didactyl": {
"agent": {
"provider": "ppq",
"model": "claude-haiku-4.5",
"admin_pubkey": "npub1...",
"dm_protocol": "nip04"
}
}
}
```
**Key principle:** `global.agent.providers` is the shared catalog (API keys,
base URLs, model lists). Each app's namespace (`sovereign_browser.agent`,
`client.ai`, `didactyl.agent`) picks which provider+model to use, and holds
app-specific agent config (system prompt, max_iterations, etc.). The `global`
namespace will grow over time to include other shared settings (zaps, UI,
relays). This way:
- API keys are entered once, shared everywhere
- The browser can use `gpt-4o-mini` while the client uses `claude-opus-4.6`
- Each app's agent config is independent
- Other shared settings (zaps, theme, etc.) live alongside `global.agent`
- The `sovereign://agents` config page manages both the shared providers
list AND the browser's per-app agent selection
**Plan:**
1. **Migrate `settings_sync.c` from d-tag `"sovereign_browser"` to
`"user-settings"`.** Change `SETTINGS_SYNC_D_TAG` to `"user-settings"`.
Move the browser-specific settings into a `sovereign_browser` namespace
inside the encrypted JSON (instead of top-level keys).
2. **Read `global_llm` from `d:user-settings` on startup.** After login,
fetch the user's kind 30078 `d:user-settings` event, NIP-44 decrypt,
parse `global_llm`, and populate `browser_settings_t` agent fields. Also
parse the `sovereign_browser` namespace for browser settings.
3. **Read-modify-write on every settings change.** When any settings change
(browser settings OR agent LLM config), do a read-modify-write:
fetch the current event, decrypt, patch the relevant namespace
(`global_llm` or `sovereign_browser`), re-encrypt, publish. This
preserves other apps' namespaces (`global_zaps`, `global_ui`, etc.).
4. **Multi-provider support.** The `global_llm.providers` array allows
multiple providers. The `sovereign://agents` config page should let the
user manage multiple providers (add/remove/edit), select the active one,
and fetch models per provider.
5. **Backward compatibility.** On first load after migration, if a
`d:sovereign_browser` event exists but no `d:user-settings` does, read
the old event and migrate the data into the new `d:user-settings` shape.
Then publish the merged event and stop using the old d-tag.
**Modified files:**
- [`src/settings_sync.h`](src/settings_sync.h) — Change
`SETTINGS_SYNC_D_TAG` to `"user-settings"`.
- [`src/settings_sync.c`](src/settings_sync.c) — Move browser settings into
`sovereign_browser` namespace, add read-modify-write logic, add
`global_llm` read/write, add backward-compat migration from old d-tag.
- [`src/settings.c`](src/settings.c) — After `settings_load_user()`, call
the sync load to fetch from Nostr.
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_set()` should
trigger a debounced sync publish.
- [`src/settings.h`](src/settings.h) — Add multi-provider fields to
`browser_settings_t` (providers array, active provider index).
- [`src/relay_fetch.c`](src/relay_fetch.c) — Update the fetch filter to use
`d:user-settings` instead of `d:sovereign_browser`.
---
## Workstream 3 — Port ai.html chat UI elements
### What ai.html has that we want
From [`~/lt/client/www/ai.html`](../client/www/ai.html):
1. **Chat layout** — Left pane with conversation list + skills list, right
pane with chat thread + input area. See
[`ai.html:44-80`](../client/www/ai.html:44) for the CSS layout.
2. **Conversation persistence to Nostr** — Each conversation is saved as a
kind 30078 event with `d:{conversation-id}` and `t:client-ai-chat-v1`,
NIP-44 encrypted. See
[`ai.html:788`](../client/www/ai.html:788) and
[`ai.html:1741`](../client/www/ai.html:1741).
3. **Skills (kind 31123)** — Public skills that define system prompts,
LLM params, and tool requirements. Users can select multiple skills
whose templates are concatenated into the system prompt. See
[`~/lt/client/plans/ai-skills-integration.md`](../client/plans/ai-skills-integration.md).
4. **Provider config sidenav** — Provider dropdown, model dropdown, API key
input, Routstr payment integration.
### What to port to sovereign://agents/chat
**Phase A — Chat layout:**
- Two-pane layout: conversation list (left) + chat thread (right).
- Conversation list shows saved conversations with titles, click to load.
- "New Chat" button to start a new conversation.
- Chat thread shows messages with role labels, tool call details collapsible.
- Input area at the bottom with send button.
**Phase B — Conversation persistence to Nostr:**
- Save each conversation as kind 30078, `d:{conversation-id}`,
`t:sovereign-browser-ai-chat-v1`, NIP-44 encrypted.
- Content: `{ schema: 1, messages: [...], title: "..." }`.
- Load conversations from Nostr on page load.
- Delete conversations (kind 5 tombstone).
**Phase C — Skills (kind 31123):**
- Fetch public skills from Nostr (kind 31123 events).
- Display skills in the left pane below conversations.
- Selecting a skill applies its system prompt template.
- Skills with `requires_tool` tags are highlighted (sovereign_browser HAS
tools, so they're fully usable — unlike ai.html which has no tools).
**Phase D — Save skills/agents to Nostr:**
- UI to create/edit skills (kind 31123) with system prompt, model, temp.
- Publish to Nostr so they're shared across all three projects.
**Files to modify:**
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — Major rewrite of
`handle_agents_chat_page()` to include the two-pane layout, conversation
list, skills list. New endpoints: `sovereign://agents/conversations`,
`sovereign://agents/conversations/new`, `sovereign://agents/conversations/delete`,
`sovereign://agents/skills`, `sovereign://agents/skills/save`.
- [`src/agent_chat_store.c`](src/agent_chat_store.c) — Add functions to
load/save conversations as Nostr events (via `relay_fetch.c`).
- New: `src/agent_skills.h` / `src/agent_skills.c` — Fetch, parse, and
apply kind 31123 skills.
---
## Cross-project harmony
```
┌─────────────────────────────────┐
│ User's Nostr │
│ │
│ d:user-settings (k30078) │
│ └─ global_llm (NIP-44 enc) │
│ │
│ d:{convo-id} (k30078) │
│ └─ conversations (NIP-44) │
│ │
│ kind 31123 (public skills) │
└────────┬───────────┬────────────┘
│ │
┌───────────────────┼───────────┼───────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────┐ ┌───────────────┐ ┌───────────────┐ ┌──────────┐
│ client │ │ sovereign_ │ │ sovereign_ │ │ didactyl │
│ ai.html │ │ browser │ │ browser │ │ daemon │
│ │ │ agents config │ │ agents/chat │ │ │
│ read/write │ │ read/write │ │ read/write │ │ read/ │
│ global_llm │ │ global_llm │ │ conversations│ │ write │
│ read/write │ │ │ │ fetch/publish│ │ global │
│ conversations │ │ │ │ skills │ │ _llm │
│ publish/fetch │ │ │ │ │ │ fetch │
│ skills │ │ │ │ │ │ skills │
└─────────────────┘ └───────────────┘ └───────────────┘ └──────────┘
```
All three projects share:
- **`global_llm`** in `d:user-settings` — provider, API key, model
- **Conversations** in `d:{convo-id}` — encrypted chat history
- **Skills** in kind 31123 — public system prompt templates
sovereign_browser's unique advantage: it has **browser tools** (snapshot,
click, eval) + **filesystem/shell tools** that ai.html and didactyl don't
have. Skills with `requires_tool` tags are fully functional in
sovereign_browser.
---
## Phasing
**Phase 1 (immediate):** Fix chat UI message rendering bug.
**Phase 2:** Kind 30078 `global_llm` sync — read from Nostr on startup,
write on config change. Single-provider first.
**Phase 3:** Port ai.html chat layout — two-pane, conversation list,
conversation persistence to Nostr.
**Phase 4:** Skills (kind 31123) — fetch, display, apply, create.
**Phase 5:** Multi-provider support in `global_llm`.

320
plans/embedded-agent.md Normal file
View File

@@ -0,0 +1,320 @@
# Embedded Agent — In-Browser LLM with First-Class MCP Access
## Concurrency model — long-running tasks
The agent loop runs on a **background GThread**, not the GTK main thread. This
is critical for long-running tasks like "follow each link, download images,
write a summary for each" which may involve dozens of LLM calls and hundreds
of tool calls.
```mermaid
sequenceDiagram
participant UI as Chat UI main thread
participant Chat as agent_chat.c
participant Thread as Agent Loop background thread
participant LLM as LLM API
participant Main as GTK main loop
participant Tools as agent_tools_dispatch
UI->>Chat: POST sovereign://agents/send?text=...
Chat->>Thread: g_thread_new agent_loop_run
Chat-->>UI: 200 OK session started
loop ReAct iterations
Thread->>LLM: POST chat/completions blocking
LLM-->>Thread: response + tool_calls
Thread->>Thread: persist assistant message
alt has tool_calls
loop each tool call
Thread->>Main: g_idle_add dispatch_tool
Main->>Tools: agent_tools_dispatch sync JS eval
Tools-->>Main: result
Main-->>Thread: result via GAsyncQueue
Thread->>Thread: persist tool result
end
else no tool_calls
Thread->>Thread: mark session complete
end
end
Thread->>Main: g_idle_add update_ui final
```
### Threading rules
1. **LLM HTTP calls** happen on the background thread — `soup_session_send`
blocks in-thread, the GTK UI stays responsive.
2. **Browser tool dispatch** (snapshot, click, eval, etc.) MUST run on the
GTK main thread because WebKitGTK is not thread-safe. The background
thread schedules each tool call via `g_idle_add()` and waits for the
result on a `GAsyncQueue` or a per-call `GMainLoop` (same pattern the
MCP HTTP handler uses for sync JS eval).
3. **Filesystem + shell tools** run directly on the background thread — they
don't touch GTK or WebKit, so no main-thread hop needed. This keeps
long shell commands from blocking the UI.
4. **SQLite writes** use the existing `SQLITE_OPEN_FULLMUTEX` connection
(thread-safe). The background thread can call `db_kv_set` /
`agent_chat_store_*` directly.
5. **UI updates** happen via `g_idle_add()` — the background thread pushes
status updates (current tool, iteration count, partial output) to a
shared struct, and an idle callback renders them in the chat page.
6. **Cancellation** — a `g_atomic_int` cancel flag is checked at the top of
each loop iteration. The chat UI's "Stop" button sets it. The background
thread exits cleanly at the next check point.
### Iteration cap
The default `max_iterations` is **100** (configurable on
`sovereign://agents`), not 20. Long tasks like "follow 30 links" need
multiple tool calls per link (open, snapshot, extract, download, write) —
easily 100+ calls. The cap is a safety valve, not a tight limit. The user
can raise it in settings for very long batch jobs.
### Status polling
The chat UI polls `sovereign://agents/status?session=...` every 500ms while
a session is active. The response includes:
- `state`: `idle` | `thinking` | `tool_call` | `complete` | `error` | `cancelled`
- `iteration`: current iteration number
- `current_tool`: name of the tool being executed (if any)
- `last_message`: most recent assistant text (for progressive display)
- `error`: error message if state is `error`
This gives the user live visibility into long-running tasks without
streaming complexity.
## Goal
Embed an LLM-powered agent directly inside sovereign_browser. The user types
`; <message>` in the URL bar to talk to the agent. The agent has first-class
access to the browser's own MCP tool set (snapshot, click, eval, tabs, etc.)
plus full filesystem and shell access (the browser runs in a dedicated Qubes
qube, so arbitrary command execution is acceptable and intended).
Provider config (base URL, API key, model name) is managed on a new
`sovereign://agents` internal page. Chat history is persisted per-session in
SQLite so it can be fed back as context on follow-up messages.
## User-facing flow
```mermaid
flowchart LR
User[User types in URL bar] --> Check{Starts with semicolon?}
Check -- yes --> Agent[Embedded Agent Chat UI]
Check -- no --> Nav[Normal navigation]
Agent --> LLM[OpenAI-compatible API call]
LLM --> ToolLoop{Tool calls in response?}
ToolLoop -- yes --> Dispatch[agent_tools_dispatch via internal MCP loopback]
Dispatch --> ToolLoop
ToolLoop -- no --> Render[Render assistant message in chat UI]
Render --> User
```
## Architecture
```mermaid
flowchart TB
subgraph URLBar
Entry[GtkEntry on_url_activate]
end
subgraph AgentModule
Router[agent_chat_route_input]
Client[agent_llm.c — HTTP client]
Loop[agent_loop.c — tool-call loop]
Store[agent_chat_store.c — SQLite persistence]
end
subgraph BrowserCore
Tools[agent_tools_dispatch]
FsTools[agent_fs_tools.c — fs + shell tools]
Bridge[nostr_bridge.c — sovereign:// scheme]
end
subgraph External
API[OpenAI-compatible API]
end
subgraph UI
ChatPage[sovereign://agents/chat — HTML/JS chat UI]
ConfigPage[sovereign://agents — provider config]
end
Entry --> Router
Router -->|chat message| Client
Client -->|HTTPS POST| API
API -->|JSON response| Client
Client --> Loop
Loop -->|tool call| Tools
Loop -->|fs/shell call| FsTools
Tools --> BrowserCore
Loop --> Store
Store --> ChatPage
Bridge --> ChatPage
Bridge --> ConfigPage
```
### Key design decisions
1. **Reuse `agent_tools_dispatch()`** — The embedded agent calls the exact same
C dispatch function the external MCP server uses. No parallel tool
implementation. The agent passes `conn = NULL` so async JS tools use the
sync `agent_js_eval_sync()` path (same as the MCP HTTP handler).
2. **New system tools (`agent_fs_tools.c`)**`fs_read`, `fs_write`,
`fs_list`, `fs_mkdir`, `fs_delete`, `shell_exec`. These are registered
alongside the existing browser tools so both the embedded agent and
external MCP clients can use them. Full shell access — the Qubes qube
provides the sandbox.
3. **OpenAI-compatible HTTP client (`agent_llm.c`)** — Uses libsoup-3.0
(already linked) to POST to `{base_url}/chat/completions` with the
`tools` array built from the same `tool_defs[]` catalog in
[`agent_mcp.c`](src/agent_mcp.c:130). Streams or polls; parses tool calls
from the response. One client covers OpenAI, OpenRouter, Ollama, LM Studio,
Groq, etc. via base URL + key.
4. **Tool-call loop (`agent_loop.c`)** — Standard ReAct loop, runs on a
background `GThread` (see Concurrency model above):
- Build messages array (system prompt + persisted history + new user msg).
- Call LLM (blocking HTTP on the background thread).
- If response contains `tool_calls`, dispatch each:
- Browser tools → hop to GTK main thread via `g_idle_add()` + wait on
`GAsyncQueue` (WebKitGTK is not thread-safe).
- Filesystem/shell tools → run directly on the background thread.
- Append tool results to messages, call LLM again.
- Repeat until no more tool calls → render final assistant text.
- Cap at N iterations (default 100, configurable) to prevent infinite
loops. Check cancel flag at the top of each iteration.
5. **Chat persistence (`agent_chat_store.c`)** — New SQLite tables
(`agent_sessions`, `agent_messages`) in the per-user `browser.db`. Each
session has an id, title, created_at. Messages store role
(user/assistant/tool), content, and tool-call JSON. The URL-bar `;` command
reuses the most recent session (or starts a new one if none exists).
6. **`sovereign://agents` config page** — Rendered by `nostr_bridge.c` (same
pattern as `sovereign://settings`). Fields: provider name, base URL, API
key, model name, system prompt (optional). Saved via
`sovereign://agents/set?key=...&value=...` to the `key_value` table
(existing `db_kv_set`). API key stored in the key_value table; since this
is a dedicated qube, that's acceptable.
7. **`sovereign://agents/chat` chat UI** — A lightweight HTML/JS page
(rendered by `nostr_bridge.c`) that:
- Shows the message history (loaded from SQLite via a
`sovereign://agents/messages?session=...` endpoint).
- Has an input box for follow-up messages (POSTed to
`sovereign://agents/send?session=...&text=...`).
- Polls `sovereign://agents/status?session=...` for in-progress tool calls
and streams the assistant's final response.
- The URL-bar `;` shortcut navigates here with the message pre-filled and
auto-submits.
8. **URL-bar `;` routing** — In [`on_url_activate`](src/tab_manager.c:913),
check if `text[0] == ';'`. If so, extract the message (`text + 1`, trimmed)
and call `agent_chat_route_input(message)` instead of `normalize_url()`.
The function opens `sovereign://agents/chat` in the active tab (or a new
tab if the active tab isn't already the chat page) and kicks off the
agent loop. If the message is empty (`;` alone), just open the chat page
without sending.
9. **System prompt** — A default system prompt explains the agent's
capabilities: it controls a web browser via MCP tools, has filesystem and
shell access, and should use the snapshot+ref pattern for page
interaction. The user can override this on `sovereign://agents`.
## New files
| File | Purpose |
|------|---------|
| `src/agent_llm.h` / `src/agent_llm.c` | OpenAI-compatible HTTP client (libsoup). Sends chat-completions request with tools, parses response + tool_calls. |
| `src/agent_loop.h` / `src/agent_loop.c` | ReAct tool-call loop. Orchestrates LLM calls ↔ tool dispatch. |
| `src/agent_chat_store.h` / `src/agent_chat_store.c` | SQLite persistence for chat sessions and messages. |
| `src/agent_fs_tools.h` / `src/agent_fs_tools.c` | Filesystem + shell tools (fs_read, fs_write, fs_list, fs_mkdir, fs_delete, shell_exec). |
| `src/agent_chat.h` / `src/agent_chat.c` | High-level entry point: `agent_chat_route_input()`, session management, bridges URL-bar → loop → UI. |
## Modified files
| File | Change |
|------|--------|
| [`src/tab_manager.c`](src/tab_manager.c:913) | `on_url_activate`: detect `;` prefix, route to `agent_chat_route_input()`. |
| [`src/nostr_bridge.c`](src/nostr_bridge.c:1723) | Add `sovereign://agents`, `sovereign://agents/chat`, `sovereign://agents/set`, `sovereign://agents/messages`, `sovereign://agents/send`, `sovereign://agents/status` route handlers. |
| [`src/agent_mcp.c`](src/agent_mcp.c:130) | Export `tool_defs[]` / `build_tools_list()` (or move to a shared header) so `agent_llm.c` can build the OpenAI tools array from the same catalog. Add fs/shell tool defs. |
| [`src/agent_tools.c`](src/agent_tools.c) | Dispatch fs/shell tools (or route them via a new `agent_fs_tools_dispatch()` called from the same dispatcher). |
| [`src/db.c`](src/db.c) / [`src/db.h`](src/db.h) | Add `agent_sessions` + `agent_messages` tables and CRUD functions. |
| [`src/settings.h`](src/settings.h) / [`src/settings.c`](src/settings.c) | Add agent provider settings fields (base_url, api_key, model, system_prompt). |
| [`Makefile`](Makefile) | Add new `.c` files to `SRC`. |
## SQLite schema
```sql
CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY, -- UUID
title TEXT,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT, -- 'user' | 'assistant' | 'tool' | 'system'
content TEXT, -- message text (or tool result JSON)
tool_calls TEXT, -- JSON array of tool calls (assistant msgs)
tool_call_id TEXT, -- for role='tool': which call this answers
created_at INTEGER,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
ON agent_messages(session_id, created_at);
```
## Tool catalog additions (fs + shell)
| Tool | Description |
|------|-------------|
| `fs_read` | Read a file's contents (text). Params: `path`. |
| `fs_write` | Write text to a file (overwrite). Params: `path`, `content`. |
| `fs_list` | List directory entries. Params: `path`. |
| `fs_mkdir` | Create a directory (recursive). Params: `path`. |
| `fs_delete` | Delete a file or empty directory. Params: `path`. |
| `shell_exec` | Run a shell command, return stdout+stderr+exit code. Params: `command`, `timeout_ms` (default 30000). |
## OpenAI tools array format
The `agent_llm.c` client builds the `tools` field from the shared
`tool_defs[]` array, converting each entry to the OpenAI format:
```json
{
"type": "function",
"function": {
"name": "snapshot",
"description": "Get the accessibility tree...",
"parameters": { ...schema... }
}
}
```
## Concurrency note
See the **Concurrency model** section at the top of this document for the
full design. Summary: the agent loop runs on a background `GThread`. LLM
HTTP calls block in-thread. Browser tool calls hop to the GTK main thread
via `g_idle_add()` + `GAsyncQueue` (WebKitGTK is not thread-safe).
Filesystem and shell tools run directly on the background thread. The UI
polls `sovereign://agents/status` for live progress. A cancel flag allows
the user to stop long-running tasks.
## Phasing
The work breaks into two phases that can be implemented sequentially:
**Phase 1 — Foundation:** fs/shell tools, LLM client, tool-call loop, chat
persistence, `sovereign://agents` config page, URL-bar `;` routing, basic
chat UI. After Phase 1 the example task ("save links to ~/temp/links.txt")
works end-to-end.
**Phase 2 — Polish:** streaming responses, tool-call progress display in the
chat UI, session list / history sidebar, editable system prompt per session,
multi-session support from the URL bar, error recovery UI.

View File

@@ -0,0 +1,179 @@
# Embedded Web Content — sovereign:// pages from files
## Problem
The `sovereign://agents/chat` page is hardcoded as a ~800-line C string literal
in [`src/nostr_bridge.c`](src/nostr_bridge.c) (`handle_agents_chat_page()`).
This causes:
- **Bugs** — C string escaping errors (`\"`, `\\`, `%%`) are easy to make and
hard to spot. The current "messages don't render" and "New Chat doesn't work"
bugs are likely caused by this.
- **Maintenance nightmare** — No syntax highlighting, no IDE support, no
formatting tools work on the embedded HTML/CSS/JS.
- **Can't copy ai.html** — The user wants to port `~/lt/client/www/ai.html`'s
structure, but that's a standalone HTML file with separate CSS/JS. Embedding
it as a C string would be thousands of lines of escaped strings.
## Solution: Copy c-relay's approach
`~/lt/c-relay` solves this with a build-time embedding script:
1. **Author web files as normal files** in a `www/` directory (HTML, CSS, JS —
no escaping needed, full IDE support).
2. **`embed_web_files.sh`** — A shell script that runs at build time. It uses
`hexdump` to convert each file into a C byte array
(`0x3c,0x21,0x44,...`) in an auto-generated `src/embedded_web_content.c`.
3. **`embedded_web_content.h`** — Declares `embedded_file_t` and
`get_embedded_file(path)`.
4. **`embedded_web_content.c`** — Auto-generated byte arrays + a lookup table
mapping paths to arrays.
5. **The `sovereign://` handler** calls `get_embedded_file()` to serve the
embedded content instead of building HTML with `g_strdup_printf()`.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ www/ (normal files, edit freely) │
│ agents/chat.html (standalone HTML) │
│ agents/chat.css (separate CSS) │
│ agents/chat.js (separate JS) │
│ agents/config.html (provider config page) │
│ agents/config.css │
│ agents/config.js │
│ settings.html │
│ bookmarks.html │
│ ... │
└─────────────────────────────────────────────────────────────┘
▼ embed_web_files.sh (build time)
┌─────────────────────────────────────────────────────────────┐
│ src/embedded_web_content.c (auto-generated, do not edit) │
│ static const unsigned char agents_chat_html_data[] = { │
│ 0x3c,0x21,0x44,0x4f,0x43,0x54,0x59,0x50,0x45,... │
│ }; │
│ static const size_t agents_chat_html_size = 12345; │
│ ... │
│ static embedded_file_t embedded_files[] = { │
│ {"agents/chat.html", agents_chat_html_data, ...}, │
│ {"agents/chat.css", agents_chat_css_data, ...}, │
│ {"agents/chat.js", agents_chat_js_data, ...}, │
│ {NULL, NULL, 0, NULL} │
│ }; │
└─────────────────────────────────────────────────────────────┘
▼ get_embedded_file(path)
┌─────────────────────────────────────────────────────────────┐
│ src/nostr_bridge.c (sovereign:// handler) │
│ on_sovereign_scheme(): │
│ if (strncmp(uri, "sovereign://agents/chat", ...) == 0) │
│ serve_embedded_file(request, "agents/chat.html"); │
│ ... │
└─────────────────────────────────────────────────────────────┘
```
## Implementation plan
### Phase 1 — Set up the embedding infrastructure
1. **Create `www/` directory** at the project root for web files.
2. **Create `embed_web_files.sh`** (copy from c-relay, adapt for our file
structure). It scans `www/**/*.html`, `www/**/*.css`, `www/**/*.js` and
generates `src/embedded_web_content.c` + `src/embedded_web_content.h`.
3. **Create `src/embedded_web_content.h`**:
```c
typedef struct {
const char *path; /* e.g. "agents/chat.html" */
const unsigned char *data;
size_t size;
const char *mime_type; /* "text/html", "text/css", "application/javascript" */
} embedded_file_t;
const embedded_file_t *get_embedded_file(const char *path);
```
4. **Add a `serve_embedded_file()` helper** in `nostr_bridge.c` that calls
`get_embedded_file()` and responds with the bytes via `respond_bytes()`.
5. **Update `Makefile`** to run `embed_web_files.sh` before compiling, and
add `src/embedded_web_content.c` to `SRC`.
6. **Update `.gitignore`** to ignore `src/embedded_web_content.c` and
`src/embedded_web_content.h` (they're auto-generated).
### Phase 2 — Migrate the chat page to files
1. **Create `www/agents/chat.html`** — Extract the HTML structure from
`handle_agents_chat_page()` into a standalone HTML file. No C string
escaping needed.
2. **Create `www/agents/chat.css`** — Extract the CSS into a separate file.
Link it from the HTML: `<link rel="stylesheet" href="sovereign://agents/chat.css">`.
3. **Create `www/agents/chat.js`** — Extract the JavaScript into a separate
file. Link it: `<script src="sovereign://agents/chat.js"></script>`.
4. **Add routes** in `on_sovereign_scheme()`:
- `sovereign://agents/chat` → serve `agents/chat.html`
- `sovereign://agents/chat.css` → serve `agents/chat.css`
- `sovereign://agents/chat.js` → serve `agents/chat.js`
5. **Fix the bugs** while extracting:
- **Messages not rendering:** The `renderMessage()` function must include
`data-msg-index="N"` on `.msg-bubble-content` and `.msg-bubble-menu-host`
elements so `renderBubbleContent()` and `mountDotMenu()` can find them.
- **New Chat not working:** Verify `sovereign://agents/conversations/new`
creates a new session and sets it as active. The `newChat()` JS must
clear the message list and reset `lastCount = -1`.
### Phase 3 — Migrate other sovereign:// pages
Once the chat page works, migrate the other pages the same way:
- `sovereign://agents` (config page) → `www/agents/config.html` + `.css` + `.js`
- `sovereign://settings` → `www/settings.html` + `.css` + `.js`
- `sovereign://bookmarks` → `www/bookmarks.html` + `.css` + `.js`
- `sovereign://profile` → `www/profile.html` + `.css` + `.js`
Each page keeps its C-side data endpoints (`sovereign://agents/set`,
`sovereign://agents/messages`, etc.) but the page HTML/CSS/JS moves to files.
### Phase 4 — Port ai.html features
With the chat page as a standalone HTML file, we can directly copy:
- The ai.html message bubble CSS (from `messaging-ui.css`)
- The dot-menu CSS (from `dot-menu.css`)
- The markdown renderer (use `marked.js` from the client's vendor libs, or
keep our minimal renderer)
- The conversation list layout
- The skills list layout
- The provider config sidenav
## Benefits
- **No C string escaping** — Edit HTML/CSS/JS normally with full IDE support
- **Syntax highlighting** — IDEs recognize `.html`, `.css`, `.js` files
- **Can copy ai.html directly** — Just copy the relevant sections
- **Smaller `nostr_bridge.c`** — The 800-line chat page string literal is gone
- **Easier debugging** — Browser dev tools show the actual HTML, not escaped strings
- **Build-time embedding** — Files are bundled in the binary, no external files needed at runtime
## Files to create
| File | Purpose |
|------|---------|
| `embed_web_files.sh` | Build script: converts www/ files to C byte arrays |
| `src/embedded_web_content.h` | Header for the embedded file lookup |
| `src/embedded_web_content.c` | Auto-generated byte arrays (gitignored) |
| `www/agents/chat.html` | Chat page HTML |
| `www/agents/chat.css` | Chat page CSS |
| `www/agents/chat.js` | Chat page JS |
## Files to modify
| File | Change |
|------|--------|
| `Makefile` | Run embed script, add embedded_web_content.c to SRC |
| `.gitignore` | Ignore auto-generated embedded_web_content.* |
| `src/nostr_bridge.c` | Add serve_embedded_file(), route to embedded files, remove old handle_agents_chat_page() string literal |

View File

@@ -0,0 +1,181 @@
# System Prompt as the Default Sovereign Browser Skill
## Goal
The system prompt on `sovereign://agents` is essentially the default skill for
sovereign_browser. Instead of having a separate "System Prompt" field, we
treat it as an **unsaved skill** that the user can edit, save, and share —
matching how ai.html handles skills.
## Current state
- `sovereign://agents` (config page) has a "System Prompt" textarea saved to
`sovereign_browser.agent.system_prompt` in the `d:user-settings` event.
- `sovereign://agents/chat` has a skills list (kind 31123) with checkboxes.
- The agent loop (`agent_loop.c`) uses the system prompt from settings, OR the
concatenated templates of selected skills (if any skills are selected).
## Desired state
- **Rename "System Prompt" to "Sovereign Browser Skill"** on the config page.
- The system prompt is presented as an **unsaved skill** — a skill that exists
locally but hasn't been published to Nostr yet.
- In the chat page, the "Sovereign Browser Skill" appears in the skills list
as an unsaved skill (with a "Save" button to publish it as kind 31123).
- The user can **edit** the skill (name, description, template, tools) inline,
matching ai.html's `renderSkillsEditor()` pattern.
- The user can **save** the skill to Nostr (publishes kind 31123), making it
available to other apps (client, didactyl).
- The user can **modify** saved skills the same way (if they're the author).
## Design
### On `sovereign://agents` (config page)
Replace the "System Prompt" section with a "Sovereign Browser Skill" section:
```
┌─────────────────────────────────────────────┐
│ Sovereign Browser Skill │
├─────────────────────────────────────────────┤
│ Name: [Sovereign Browser Default ] │
│ Description: [Default agent skill ] │
│ Template: │
│ ┌───────────────────────────────────────┐ │
│ │ You are an AI assistant embedded in │ │
│ │ a web browser. You have access to... │ │
│ └───────────────────────────────────────┘ │
│ Requires tools: [browser, fs, shell ] │
│ │
│ [💾 Save as Skill] (publishes to Nostr) │
└─────────────────────────────────────────────┘
```
- The template is the current system prompt value.
- "Save as Skill" publishes a kind 31123 event with the skill content.
- The skill is saved to `sovereign_browser.agent.system_prompt` locally (as
now) AND optionally published to Nostr as a skill.
### On `sovereign://agents/chat` (chat page)
The skills list shows:
1. **Sovereign Browser Skill** (unsaved) — always at the top, with a
checkbox (selected by default), an edit button, and a "Save" button.
2. **Saved skills** (kind 31123) — fetched from Nostr, with checkboxes,
edit buttons (if user-authored), and delete buttons.
When the user selects the Sovereign Browser Skill, its template is used as
the system prompt (concatenated with any other selected skills, matching the
existing `agent_skills_build_system_prompt()` logic).
### Skill editor (matching ai.html)
When the user clicks "Edit" on a skill (or the Sovereign Browser Skill), an
inline editor expands (like ai.html's `renderSkillsEditor()`):
```
▼ Sovereign Browser Default
┌──────────────────────────────────────────┐
│ Name: [Sovereign Browser Default ] │
│ Description: [Default agent skill ] │
│ Template: │
│ ┌────────────────────────────────────┐ │
│ │ You are an AI assistant embedded...│ │
│ └────────────────────────────────────┘ │
│ Requires tools: [browser, fs, shell ] │
│ │
│ [💾 Save / Update] [Cancel] │
└──────────────────────────────────────────┘
```
- For the Sovereign Browser Skill (unsaved): "Save" publishes it to Nostr as
a new kind 31123 event AND updates the local system prompt.
- For saved skills (user-authored): "Save / Update" re-publishes the kind
31123 event with the edited content.
- For saved skills (not user-authored): "View Only (not your skill)" — no
edit, matching ai.html's `canSave` logic.
## Implementation
### Phase 1 — Rename + restructure the config page
1. **`www/agents/config.html` + `config.js`** — Replace the "System Prompt"
section with a "Sovereign Browser Skill" section with Name, Description,
Template, and Requires Tools fields. Add a "Save as Skill" button.
2. **`src/nostr_bridge.c`** — Update `handle_agents_set()` to handle the new
skill fields (`agent.skill_name`, `agent.skill_description`,
`agent.skill_template`, `agent.skill_requires_tools`). These are saved to
`sovereign_browser.agent` in the `d:user-settings` event (replacing the
old `system_prompt` field).
3. **`src/agent_loop.c`** — Update the system prompt logic: use
`sovereign_browser.agent.skill_template` as the default system prompt
(instead of `system_prompt`). If skills are selected, concatenate their
templates (as now).
### Phase 2 — Show the Sovereign Browser Skill in the chat page
1. **`www/agents/chat.js`** — In `renderSkillList()`, add the Sovereign
Browser Skill at the top of the list as an "unsaved" skill. It has:
- A checkbox (selected by default)
- The skill name (from `sovereign_browser.agent.skill_name`)
- An "Edit" button that opens the inline editor
- A "Save" button that publishes it to Nostr
2. **`src/agent_skills.c`** — Add a function
`agent_skills_get_default()` that returns the Sovereign Browser Skill
from the local settings (not from Nostr). This is used by the chat page
to show the unsaved skill.
3. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/default`
endpoint that returns the Sovereign Browser Skill from settings.
### Phase 3 — Skill editor in the chat page
1. **`www/agents/chat.js`** — Implement `renderSkillEditor(skill)` matching
ai.html's `renderSkillsEditor()`:
- Expandable `<details>` for each selected skill
- Name, Description, Template, Requires Tools fields
- "Save / Update" button (for user-authored or unsaved skills)
- "View Only" for skills not authored by the user
2. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/update`
endpoint that re-publishes an existing skill (kind 31123) with edited
content. For the Sovereign Browser Skill, this also updates the local
settings.
3. **`src/agent_skills.c`** — Add `agent_skills_update(d_tag, name,
description, content, requires_tools)` that fetches the existing skill
event, updates its content, re-signs, and re-publishes.
### Phase 4 — Wire the default skill into the agent loop
1. **`src/agent_loop.c`** — The system prompt is now:
- If the Sovereign Browser Skill is selected (default): use its template
- If other skills are selected: concatenate their templates
- If no skills selected: use the Sovereign Browser Skill template as
fallback (it's always the default)
2. **`src/agent_skills.c`** — Update `agent_skills_build_system_prompt()` to
always include the Sovereign Browser Skill template (either as the base
prompt, or concatenated with selected skills).
## Files to modify
| File | Change |
|------|--------|
| `www/agents/config.html` | Rename "System Prompt" to "Sovereign Browser Skill", restructure fields |
| `www/agents/config.js` | Handle new skill fields, "Save as Skill" button |
| `www/agents/chat.js` | Show Sovereign Browser Skill in skills list, implement skill editor |
| `www/agents/chat.css` | Style the skill editor (matching ai.html) |
| `src/nostr_bridge.c` | New endpoints: `skills/default`, `skills/update`; update `handle_agents_set()` |
| `src/agent_skills.c` / `.h` | `agent_skills_get_default()`, `agent_skills_update()` |
| `src/agent_loop.c` | Use skill template as system prompt |
| `src/settings.h` / `settings.c` | Rename `agent_llm_system_prompt` to `agent_skill_template`, add `agent_skill_name`, `agent_skill_description`, `agent_skill_requires_tools` |
## Reference
- `~/lt/client/www/ai.html` lines 1454-1540 — `renderSkillsEditor()` pattern
- `~/lt/client/www/ai.html` lines 1534-1538 — "Save / Update Skill" button
- `~/lt/client/www/ai.html` lines 1471 — `canSave` logic (only author can edit)

49
src/agent_chat.c Normal file
View File

@@ -0,0 +1,49 @@
/*
* agent_chat.c — high-level entry point for the embedded agent chat
*
* Bridges the URL bar to the agent loop and the chat UI page.
*/
#include "agent_chat.h"
#include "agent_loop.h"
#include "agent_chat_store.h"
#include "tab_manager.h"
#include <string.h>
#include <webkit2/webkit2.h>
#define AGENT_CHAT_URL "sovereign://agents/chat"
int agent_chat_route_input(const char *message) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->webview == NULL) {
g_printerr("[agent_chat] no active tab\n");
return -1;
}
/* Sidebar-aware behavior: if the agent sidebar is already visible,
* just send the message (don't navigate away from the current page).
* If the sidebar is not visible, toggle it on first — this opens
* the chat in the sidebar alongside the current web page, then
* sends the message. This lets the user chat about the page they're
* viewing without navigating away from it. */
if (!tab_manager_sidebar_visible()) {
tab_manager_toggle_sidebar();
}
/* If a non-empty message was provided, start the agent loop. The
* sidebar webview polls sovereign://agents/messages and will pick
* up the agent's responses automatically. */
if (message != NULL && message[0] != '\0') {
if (agent_loop_run(message) != 0) {
g_printerr("[agent_chat] agent_loop_run failed\n");
return -1;
}
}
return 0;
}
const char *agent_chat_session_id(void) {
return agent_chat_store_session_id();
}

47
src/agent_chat.h Normal file
View File

@@ -0,0 +1,47 @@
/*
* agent_chat.h — high-level entry point for the embedded agent chat
*
* Bridges the URL bar to the agent loop and the chat UI page. When the
* user types "; <message>" in the URL bar, tab_manager calls
* agent_chat_route_input(), which navigates the active tab to
* sovereign://agents/chat and kicks off the agent loop.
*/
#ifndef AGENT_CHAT_H
#define AGENT_CHAT_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* Route a user message from the URL bar to the embedded agent.
* Called when the user types "; <message>" in the URL bar.
*
* Sidebar-aware behavior:
* 1. If the agent sidebar is not visible, toggle it on (this opens the
* chat page in a narrow sidebar alongside the current web page).
* 2. If message is non-NULL and non-empty, starts the agent loop with
* that message. The sidebar webview polls for messages and shows
* the agent's responses.
* 3. If message is NULL or empty, just opens the sidebar (no message
* sent).
*
* This lets the user chat about the page they're viewing without
* navigating away from it.
*
* Returns 0 on success, -1 on error.
*/
int agent_chat_route_input(const char *message);
/*
* Get the current session ID (for the chat UI to use).
* Returns NULL if no session exists. The string is owned by the chat store.
*/
const char *agent_chat_session_id(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_CHAT_H */

177
src/agent_chat_store.c Normal file
View File

@@ -0,0 +1,177 @@
/*
* agent_chat_store.c — thin wrapper over db.c for agent chat sessions
*
* Manages the "current session" concept and provides convenience
* functions for adding messages and loading history in OpenAI format.
*/
#include "agent_chat_store.h"
#include "db.h"
#include <stdio.h>
#include <string.h>
/* ── Internal state ────────────────────────────────────────────────── */
/* Owned by the store; freed on replacement or shutdown. */
static char *g_current_session_id = NULL;
/* ── Helpers ───────────────────────────────────────────────────────── */
/*
* Replace g_current_session_id with a newly-allocated copy of `id`.
* Frees the previous value. Takes ownership of nothing (copies `id`).
* Returns a pointer to the stored string (owned by the store).
*/
static const char *store_session_id(const char *id) {
if (id == NULL) return NULL;
if (g_current_session_id) {
g_free(g_current_session_id);
}
g_current_session_id = g_strdup(id);
return g_current_session_id;
}
/* ── Public API ────────────────────────────────────────────────────── */
const char *agent_chat_store_get_session(void) {
if (g_current_session_id) {
return g_current_session_id;
}
/* Try to load the latest existing session. */
char *latest = db_agent_session_get_latest();
if (latest) {
const char *stored = store_session_id(latest);
g_free(latest);
return stored;
}
/* No sessions exist — create a new one. */
char *id = db_agent_session_create(NULL);
if (id == NULL) {
g_printerr("[agent_chat_store] failed to create new session\n");
return NULL;
}
const char *stored = store_session_id(id);
g_free(id);
return stored;
}
const char *agent_chat_store_new_session(const char *title) {
char *id = db_agent_session_create(title);
if (id == NULL) {
g_printerr("[agent_chat_store] failed to create new session\n");
return NULL;
}
const char *stored = store_session_id(id);
g_free(id);
return stored;
}
const char *agent_chat_store_set_session(const char *session_id) {
if (session_id == NULL) return NULL;
return store_session_id(session_id);
}
int agent_chat_store_add_user_message(const char *content) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return -1;
if (content == NULL) return -1;
int row = db_agent_message_add(sid, "user", content, NULL, NULL);
if (row < 0) {
g_printerr("[agent_chat_store] failed to add user message\n");
return -1;
}
return 0;
}
int agent_chat_store_add_assistant_message(const char *content,
const char *tool_calls_json) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return -1;
int row = db_agent_message_add(sid, "assistant", content,
tool_calls_json, NULL);
if (row < 0) {
g_printerr("[agent_chat_store] failed to add assistant message\n");
return -1;
}
return 0;
}
int agent_chat_store_add_tool_result(const char *tool_call_id,
const char *result_json) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return -1;
if (tool_call_id == NULL || result_json == NULL) return -1;
int row = db_agent_message_add(sid, "tool", result_json, NULL,
tool_call_id);
if (row < 0) {
g_printerr("[agent_chat_store] failed to add tool result\n");
return -1;
}
return 0;
}
cJSON *agent_chat_store_get_messages(void) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return NULL;
cJSON *raw = db_agent_message_list(sid);
if (raw == NULL) {
g_printerr("[agent_chat_store] failed to load messages\n");
return NULL;
}
/* Convert to OpenAI-format array. */
cJSON *out = cJSON_CreateArray();
cJSON *msg;
cJSON_ArrayForEach(msg, raw) {
cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role");
const char *role = (cJSON_IsString(role_item))
? role_item->valuestring : "";
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(msg, "content");
cJSON *tool_calls_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
cJSON *tool_call_id_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id");
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "role", role);
/* content: include only if non-null. */
if (cJSON_IsString(content_item)) {
cJSON_AddStringToObject(obj, "content", content_item->valuestring);
}
/* tool_calls: parse the stored JSON string into an array object. */
if (cJSON_IsString(tool_calls_item) &&
tool_calls_item->valuestring[0] != '\0') {
cJSON *parsed = cJSON_Parse(tool_calls_item->valuestring);
if (parsed) {
cJSON_AddItemToObject(obj, "tool_calls", parsed);
} else {
/* Store the raw string if it isn't valid JSON. */
cJSON_AddStringToObject(obj, "tool_calls",
tool_calls_item->valuestring);
}
}
/* tool_call_id: for role="tool". */
if (cJSON_IsString(tool_call_id_item)) {
cJSON_AddStringToObject(obj, "tool_call_id",
tool_call_id_item->valuestring);
}
cJSON_AddItemToArray(out, obj);
}
cJSON_Delete(raw);
return out;
}
const char *agent_chat_store_session_id(void) {
return g_current_session_id;
}

89
src/agent_chat_store.h Normal file
View File

@@ -0,0 +1,89 @@
/*
* agent_chat_store.h — thin wrapper over db.c for agent chat sessions
*
* Manages the "current session" concept and provides convenience
* functions for adding messages and loading history in OpenAI format.
*
* The store keeps a static g_current_session_id internally. When
* agent_chat_store_get_session() is called and no current session is
* set, it loads the latest session from the database (or creates a new
* one if none exists).
*
* Thread safety: the underlying database uses SQLITE_OPEN_FULLMUTEX, so
* these functions can be called from the background agent thread.
*/
#ifndef AGENT_CHAT_STORE_H
#define AGENT_CHAT_STORE_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Get or create the current chat session. If no session exists, creates
* a new one. Returns the session ID (owned by the store, do not free).
* Returns NULL on error.
*/
const char *agent_chat_store_get_session(void);
/*
* Start a new chat session. Returns the session ID (owned by the store).
* Returns NULL on error.
*/
const char *agent_chat_store_new_session(const char *title);
/*
* Set the current session to an existing session id. The session must
* already exist in the database (e.g. created by db_agent_session_create).
* Returns a pointer to the stored session id (owned by the store), or
* NULL on error.
*/
const char *agent_chat_store_set_session(const char *session_id);
/*
* Add a user message to the current session.
* Returns 0 on success, -1 on error.
*/
int agent_chat_store_add_user_message(const char *content);
/*
* Add an assistant message (with optional tool_calls) to the current session.
* Returns 0 on success, -1 on error.
*/
int agent_chat_store_add_assistant_message(const char *content,
const char *tool_calls_json);
/*
* Add a tool result message to the current session.
* Returns 0 on success, -1 on error.
*/
int agent_chat_store_add_tool_result(const char *tool_call_id,
const char *result_json);
/*
* Load the full message history for the current session as a cJSON array
* (suitable for sending to the LLM API). Each message is in OpenAI format:
* {"role":"user","content":"..."}
* {"role":"assistant","content":"...","tool_calls":[...]}
* {"role":"tool","tool_call_id":"...","content":"..."}
* Returns NULL on error. Caller must cJSON_Delete().
*/
cJSON *agent_chat_store_get_messages(void);
/*
* Get the session ID for the current session (or NULL if none).
* The returned string is owned by the store.
*/
const char *agent_chat_store_session_id(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_CHAT_STORE_H */

799
src/agent_conversations.c Normal file
View File

@@ -0,0 +1,799 @@
/*
* agent_conversations.c — Nostr conversation persistence for agent chat
*
* Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable
* events. Each conversation is stored with:
* d-tag: "{uuid}" (no prefix — matches ai.html for cross-app sharing)
* t-tag: "client-ai-chat-v1"
*
* The event content is NIP-44 encrypted (self-to-self) JSON:
* { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] }
*
* Reuses the NIP-44 encryption + event signing + relay publishing patterns
* from settings_sync.c and bookmarks.c.
*
* See plans/cross-project-agent-sync.md (Workstream 3) for the full design.
*/
#include "agent_conversations.h"
#include "agent_chat_store.h"
#include "db.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global state ───────────────────────────────────────────────────── */
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static int g_have_signer = 0;
/* ── Init ───────────────────────────────────────────────────────────── */
void agent_conversations_init(nostr_signer_t *signer,
const char *pubkey_hex) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
} else {
g_pubkey[0] = '\0';
}
}
void agent_conversations_set_signer(nostr_signer_t *signer,
const char *pubkey_hex) {
agent_conversations_init(signer, pubkey_hex);
}
/* ── Helpers ────────────────────────────────────────────────────────── */
/* Parse bootstrap relays from settings into an array.
* Returns the count. Fills urls_out (pointers into relay_buf).
* Caller must g_free(relay_buf) after use. (Same pattern as
* settings_sync.c and bookmarks.c.) */
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
const browser_settings_t *s = settings_get();
*relay_buf = g_strdup(s->bootstrap_relays);
int count = 0;
char *saveptr = NULL;
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
while (line != NULL && count < max) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
urls_out[count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
return count;
}
/* Encrypt a plaintext string with NIP-44 (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *encrypt_content(const char *plaintext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
char *ciphertext = NULL;
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
&ciphertext);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
g_printerr("[agent_conv] NIP-44 encrypt failed: %d\n", rc);
return NULL;
}
return ciphertext;
}
/* Decrypt a NIP-44 ciphertext (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *decrypt_content(const char *ciphertext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
char *plaintext = NULL;
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
&plaintext);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
g_printerr("[agent_conv] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* Check whether an event has a tag [name, value]. Returns 1/0. */
static int event_has_tag(const cJSON *event, const char *name,
const char *value) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) {
return 1;
}
}
return 0;
}
/* Get the value of the first tag with the given name. Returns a pointer
* into the event's tags (or NULL). Not owned by caller. */
static const char *event_tag_value(const cJSON *event, const char *name) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return NULL;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1)) {
return t1->valuestring;
}
}
return NULL;
}
/* Build the full d-tag for a conversation id. With the empty prefix
* (AGENT_CONV_D_PREFIX ""), this is just a copy of the conversation id,
* matching ai.html's d:{conversation-id} format. Caller must g_free. */
static char *build_d_tag(const char *conversation_id) {
return g_strdup_printf("%s%s", AGENT_CONV_D_PREFIX, conversation_id);
}
/* Extract the conversation id from a d-tag value. With the empty prefix
* the id is the entire d-tag value. Returns NULL only if d_value is NULL. */
static const char *d_tag_to_id(const char *d_value) {
size_t plen = strlen(AGENT_CONV_D_PREFIX);
if (d_value == NULL) return NULL;
if (strncmp(d_value, AGENT_CONV_D_PREFIX, plen) != 0) return NULL;
return d_value + plen;
}
/* Derive a conversation title from the first user message in a message
* array. Returns a newly allocated string (caller must g_free), or
* g_strdup("New Chat") if no user message is found. */
static char *derive_title(const cJSON *messages) {
if (!cJSON_IsArray(messages)) return g_strdup("New Chat");
cJSON *msg;
cJSON_ArrayForEach(msg, messages) {
cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role");
if (cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(msg, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
/* Truncate to 60 chars, replace newlines with spaces. */
char buf[64];
size_t i = 0;
const char *s = content->valuestring;
while (s[i] && i < 60) {
buf[i] = (s[i] == '\n' || s[i] == '\r') ? ' ' : s[i];
i++;
}
buf[i] = '\0';
/* Trim trailing spaces. */
while (i > 0 && buf[i - 1] == ' ') buf[--i] = '\0';
if (i == 0) return g_strdup("New Chat");
return g_strdup(buf);
}
}
}
return g_strdup("New Chat");
}
/* ── List ───────────────────────────────────────────────────────────── */
cJSON *agent_conversations_list(void) {
cJSON *result = cJSON_CreateArray();
if (g_pubkey[0] == '\0') return result;
/* Fetch all kind 30078 events for the user. We then filter by the
* t-tag. A generous limit covers all conversations. */
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) return result;
/* Deduplicate by d-tag (conversation id), keeping only the latest
* event (highest created_at) for each conversation. Without this,
* re-saving or renaming a conversation leaves the old event in the
* SQLite cache, producing duplicate list entries with the same id —
* which causes two items to get the "active" highlight in the UI. */
GHashTable *latest = g_hash_table_new_full(g_str_hash, g_str_equal,
g_free, (GDestroyNotify)cJSON_Delete);
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
/* Must have the conversation t-tag. */
if (!event_has_tag(ev, "t", AGENT_CONV_T_TAG)) continue;
/* Extract the conversation id from the d-tag. */
const char *d_val = event_tag_value(ev, "d");
const char *conv_id = d_tag_to_id(d_val);
if (conv_id == NULL) continue;
/* Extract created_at. */
cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
long updated = 0;
if (cJSON_IsNumber(created)) updated = (long)created->valuedouble;
/* Check if we already have a newer event for this conversation. */
gpointer prev = g_hash_table_lookup(latest, conv_id);
if (prev != NULL) {
cJSON *prev_created = cJSON_GetObjectItemCaseSensitive(
(cJSON *)prev, "created_at");
long prev_updated = 0;
if (cJSON_IsNumber(prev_created))
prev_updated = (long)prev_created->valuedouble;
if (prev_updated >= updated) continue; /* keep the newer one */
}
/* Store a duplicate of this event as the latest for this id. */
g_hash_table_replace(latest, g_strdup(conv_id),
cJSON_Duplicate(ev, 1));
}
/* Build the result array from the deduplicated hash table. */
GHashTableIter iter;
gpointer key, value;
g_hash_table_iter_init(&iter, latest);
while (g_hash_table_iter_next(&iter, &key, &value)) {
const char *conv_id = (const char *)key;
cJSON *ev = (cJSON *)value;
cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
long updated = 0;
if (cJSON_IsNumber(created)) updated = (long)created->valuedouble;
/* Try to decrypt the content to get the title. If decryption
* fails (e.g. no signer in read-only mode), fall back to the
* conversation id. */
char *title = NULL;
char *plaintext = decrypt_content(
cJSON_GetObjectItemCaseSensitive(ev, "content") &&
cJSON_IsString(cJSON_GetObjectItemCaseSensitive(ev, "content"))
? cJSON_GetObjectItemCaseSensitive(ev, "content")->valuestring
: "");
if (plaintext) {
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload) {
cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title");
if (cJSON_IsString(t) && t->valuestring[0]) {
title = g_strdup(t->valuestring);
}
cJSON_Delete(payload);
}
}
if (title == NULL) title = g_strdup(conv_id);
cJSON *summary = cJSON_CreateObject();
cJSON_AddStringToObject(summary, "id", conv_id);
cJSON_AddStringToObject(summary, "title", title);
cJSON_AddNumberToObject(summary, "updated_at", (double)updated);
cJSON_AddItemToArray(result, summary);
g_free(title);
}
g_hash_table_destroy(latest);
cJSON_Delete(events);
return result;
}
/* ── Save ───────────────────────────────────────────────────────────── */
char *agent_conversations_save(const char *conversation_id,
const char *title) {
if (!g_have_signer || g_pubkey[0] == '\0') {
g_printerr("[agent_conv] No signer, cannot save\n");
return NULL;
}
/* Determine the conversation id. Generate one if not provided. */
char *conv_id = NULL;
if (conversation_id && conversation_id[0]) {
conv_id = g_strdup(conversation_id);
} else {
conv_id = g_uuid_string_random();
if (conv_id == NULL) {
g_printerr("[agent_conv] Failed to generate conversation id\n");
return NULL;
}
}
/* Fetch the current session's messages. */
cJSON *messages = agent_chat_store_get_messages();
if (messages == NULL) {
g_printerr("[agent_conv] Failed to get messages for save\n");
g_free(conv_id);
return NULL;
}
/* Determine the title:
* - If a title is provided, use it.
* - If no title but the conversation already exists on Nostr,
* preserve the existing title (don't overwrite a renamed title).
* - If no title and no existing conversation, derive from the
* first user message. */
char *use_title = NULL;
if (title && title[0]) {
use_title = g_strdup(title);
} else {
/* Try to fetch the existing event's title. */
char *existing_title = NULL;
char *d_tag_val = build_d_tag(conv_id);
if (d_tag_val && g_pubkey[0]) {
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events) {
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (cJSON_IsString(content_item)) {
char *plaintext = decrypt_content(content_item->valuestring);
if (plaintext) {
cJSON *payload = cJSON_Parse(plaintext);
if (payload) {
cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title");
if (cJSON_IsString(t) && t->valuestring[0]) {
existing_title = g_strdup(t->valuestring);
}
cJSON_Delete(payload);
}
free(plaintext);
}
}
break;
}
}
cJSON_Delete(events);
}
}
g_free(d_tag_val);
if (existing_title) {
use_title = existing_title;
} else {
use_title = derive_title(messages);
}
}
/* Build the payload: { schema: 1, title: "...", messages: [...] }.
* Only save user and assistant messages to Nostr — tool messages
* (tool call results) can be very large (e.g. entire web pages)
* and would bloat the kind 30078 event. They're kept in the local
* SQLite session for the agent loop's context but not synced. */
cJSON *payload = cJSON_CreateObject();
cJSON_AddNumberToObject(payload, "schema", 1);
cJSON_AddStringToObject(payload, "title", use_title);
cJSON *saved_msgs = cJSON_CreateArray();
cJSON *msg;
cJSON_ArrayForEach(msg, messages) {
cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role");
const char *role_str = cJSON_IsString(role) ? role->valuestring : "";
if (strcmp(role_str, "user") == 0 || strcmp(role_str, "assistant") == 0) {
cJSON_AddItemToArray(saved_msgs, cJSON_Duplicate(msg, 1));
}
}
cJSON_AddItemToObject(payload, "messages", saved_msgs);
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
g_printerr("[agent_conv] Failed to serialize payload\n");
cJSON_Delete(messages);
g_free(conv_id);
g_free(use_title);
return NULL;
}
/* Encrypt. */
char *ciphertext = encrypt_content(json);
free(json);
cJSON_Delete(messages);
if (ciphertext == NULL) {
g_free(conv_id);
g_free(use_title);
return NULL;
}
/* Build tags: [["d", "{id}"],
* ["t", "client-ai-chat-v1"],
* ["client", "sovereign_browser"]] */
cJSON *tags = cJSON_CreateArray();
char *d_tag_val = build_d_tag(conv_id);
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val));
cJSON_AddItemToArray(tags, d_tag);
g_free(d_tag_val);
cJSON *t_tag = cJSON_CreateArray();
cJSON_AddItemToArray(t_tag, cJSON_CreateString("t"));
cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG));
cJSON_AddItemToArray(tags, t_tag);
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Create and sign the event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[agent_conv] Failed to create/sign event\n");
cJSON_Delete(tags);
g_free(conv_id);
g_free(use_title);
return NULL;
}
/* Store in SQLite. */
db_store_event(event);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_conv] Saved conversation %s to %d/%d relays\n",
conv_id, success_count, relay_count);
} else {
g_print("[agent_conv] No relays configured, event stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
g_free(use_title);
return conv_id;
}
/* ── Load ───────────────────────────────────────────────────────────── */
int agent_conversations_load(const char *conversation_id) {
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
if (g_pubkey[0] == '\0') return -1;
/* Build the d-tag to look for. */
char *d_tag_val = build_d_tag(conversation_id);
/* Fetch all kind 30078 events and find the one with our d-tag.
* (db_get_latest_event doesn't filter by d-tag, so we scan.) */
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) {
g_free(d_tag_val);
return -1;
}
cJSON *found = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
cJSON_Delete(events);
g_free(d_tag_val);
if (found == NULL) {
g_printerr("[agent_conv] Conversation %s not found in cache\n",
conversation_id);
return -1;
}
/* Decrypt the content. */
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content");
if (!cJSON_IsString(content_item)) {
cJSON_Delete(found);
return -1;
}
char *plaintext = decrypt_content(content_item->valuestring);
cJSON_Delete(found);
if (plaintext == NULL) return -1;
/* Parse the payload. */
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload == NULL) {
g_printerr("[agent_conv] Failed to parse decrypted payload\n");
return -1;
}
cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title");
const char *title = (cJSON_IsString(title_item))
? title_item->valuestring : "Loaded Chat";
cJSON *messages = cJSON_GetObjectItemCaseSensitive(payload, "messages");
if (!cJSON_IsArray(messages)) {
g_printerr("[agent_conv] No messages array in payload\n");
cJSON_Delete(payload);
return -1;
}
/* Create a new agent chat session and populate it with the messages. */
const char *new_sid = agent_chat_store_new_session(title);
if (new_sid == NULL) {
cJSON_Delete(payload);
return -1;
}
/* Add each message to the new session. */
cJSON *msg;
cJSON_ArrayForEach(msg, messages) {
cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role");
cJSON *content_m = cJSON_GetObjectItemCaseSensitive(msg, "content");
cJSON *tc_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
cJSON *tcid_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id");
const char *role = (cJSON_IsString(role_item))
? role_item->valuestring : "user";
const char *content = (cJSON_IsString(content_m))
? content_m->valuestring : NULL;
/* Serialize tool_calls back to a string for storage. */
char *tc_str = NULL;
if (tc_item && !cJSON_IsNull(tc_item)) {
tc_str = cJSON_PrintUnformatted(tc_item);
}
const char *tcid = (cJSON_IsString(tcid_item) &&
tcid_item->valuestring[0])
? tcid_item->valuestring : NULL;
db_agent_message_add(new_sid, role, content, tc_str, tcid);
if (tc_str) free(tc_str);
}
cJSON_Delete(payload);
return 0;
}
/* ── Delete ─────────────────────────────────────────────────────────── */
int agent_conversations_delete(const char *conversation_id) {
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
if (g_pubkey[0] == '\0') return -1;
/* Find the event id for this conversation. */
char *d_tag_val = build_d_tag(conversation_id);
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) {
g_free(d_tag_val);
return -1;
}
char *event_id = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
if (cJSON_IsString(id_item)) {
event_id = g_strdup(id_item->valuestring);
}
break;
}
}
cJSON_Delete(events);
g_free(d_tag_val);
if (event_id == NULL) {
g_printerr("[agent_conv] Conversation %s not found for delete\n",
conversation_id);
return -1;
}
/* Publish a kind 5 deletion event referencing the conversation event.
* The content is an optional reason; tags list the event id to delete. */
if (g_have_signer) {
cJSON *tags = cJSON_CreateArray();
cJSON *e_tag = cJSON_CreateArray();
cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id));
cJSON_AddItemToArray(tags, e_tag);
cJSON *event = nostr_create_and_sign_event_with_signer(
5, "Deleted conversation", tags, g_signer, time(NULL));
cJSON_Delete(tags);
if (event) {
db_store_event(event);
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results =
synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_conv] Published deletion for %s to %d/%d relays\n",
conversation_id, success_count, relay_count);
}
g_free(relay_buf);
cJSON_Delete(event);
}
}
/* Remove the kind 30078 event from the local SQLite cache so it
* no longer appears in conversation lists. The kind 5 tombstone
* published above handles relay-side deletion. */
db_delete_event(event_id);
g_free(event_id);
return 0;
}
/* ── Rename ─────────────────────────────────────────────────────────── */
int agent_conversations_rename(const char *conversation_id,
const char *new_title) {
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
if (new_title == NULL || new_title[0] == '\0') return -1;
if (!g_have_signer || g_pubkey[0] == '\0') {
g_printerr("[agent_conv] No signer, cannot rename\n");
return -1;
}
/* Find the existing event for this conversation. */
char *d_tag_val = build_d_tag(conversation_id);
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) {
g_free(d_tag_val);
return -1;
}
cJSON *found = NULL;
char *old_event_id = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
found = cJSON_Duplicate(ev, 1);
/* Remember the old event's id so we can delete it from
* SQLite after storing the renamed event. This prevents
* duplicate events with the same d-tag. */
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
if (cJSON_IsString(id_item)) {
old_event_id = g_strdup(id_item->valuestring);
}
break;
}
}
cJSON_Delete(events);
g_free(d_tag_val);
if (found == NULL) {
g_printerr("[agent_conv] Conversation %s not found for rename\n",
conversation_id);
g_free(old_event_id);
return -1;
}
/* Decrypt the content. */
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content");
if (!cJSON_IsString(content_item)) {
cJSON_Delete(found);
return -1;
}
char *plaintext = decrypt_content(content_item->valuestring);
cJSON_Delete(found);
if (plaintext == NULL) return -1;
/* Parse the payload, replace the title, re-serialize. */
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload == NULL) {
g_printerr("[agent_conv] Failed to parse payload for rename\n");
return -1;
}
/* Replace the title field. */
cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title");
if (title_item) {
cJSON_ReplaceItemInObjectCaseSensitive(payload, "title",
cJSON_CreateString(new_title));
} else {
cJSON_AddStringToObject(payload, "title", new_title);
}
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
g_printerr("[agent_conv] Failed to serialize renamed payload\n");
return -1;
}
/* Re-encrypt. */
char *ciphertext = encrypt_content(json);
free(json);
if (ciphertext == NULL) return -1;
/* Build tags (same structure as save: d, t, client). */
cJSON *tags = cJSON_CreateArray();
char *d_tag_val2 = build_d_tag(conversation_id);
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val2));
cJSON_AddItemToArray(tags, d_tag);
g_free(d_tag_val2);
cJSON *t_tag = cJSON_CreateArray();
cJSON_AddItemToArray(t_tag, cJSON_CreateString("t"));
cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG));
cJSON_AddItemToArray(tags, t_tag);
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Create and sign the new event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[agent_conv] Failed to create/sign rename event\n");
cJSON_Delete(tags);
return -1;
}
/* Store the renamed event in SQLite. */
db_store_event(event);
/* Delete the old event from SQLite so there's only one event per
* d-tag. Without this, both the old and new events exist, and the
* dedup logic in agent_conversations_list() might pick the wrong
* one if the old event has a higher created_at. */
if (old_event_id) {
db_delete_event(old_event_id);
g_free(old_event_id);
old_event_id = NULL;
}
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_conv] Renamed conversation %s to %d/%d relays\n",
conversation_id, success_count, relay_count);
} else {
g_print("[agent_conv] No relays configured, rename stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return 0;
}

144
src/agent_conversations.h Normal file
View File

@@ -0,0 +1,144 @@
/*
* agent_conversations.h — Nostr conversation persistence for agent chat
*
* Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable
* events. Each conversation is stored with:
* d-tag: "{uuid}" (no prefix — matches ai.html so conversations are
* shared between sovereign_browser and the client)
* t-tag: "client-ai-chat-v1"
*
* The event content is NIP-44 encrypted (self-to-self) JSON:
* { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] }
*
* Reuses the NIP-44 encryption + event signing + relay publishing patterns
* from settings_sync.c and bookmarks.c.
*
* See plans/cross-project-agent-sync.md (Workstream 3) for the full design.
*/
#ifndef AGENT_CONVERSATIONS_H
#define AGENT_CONVERSATIONS_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
/* nostr_signer_t is needed for init */
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
/* The Nostr kind for arbitrary custom app data (NIP-78). */
#define AGENT_CONV_KIND 30078
/* The t-tag that identifies these conversation events. Matches ai.html
* so conversations are shared between sovereign_browser and the client. */
#define AGENT_CONV_T_TAG "client-ai-chat-v1"
/* The prefix for the d-tag. Empty — the d-tag is just the UUID, matching
* ai.html's d:{conversation-id} format. Kept as a macro for compatibility
* with build_d_tag()/d_tag_to_id(). */
#define AGENT_CONV_D_PREFIX ""
/*
* Initialize the conversation persistence module. Stores the signer + pubkey
* references for save/load/delete operations.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
*
* Call after login.
*/
void agent_conversations_init(nostr_signer_t *signer,
const char *pubkey_hex);
/*
* Update the signer reference (e.g. after switching identity).
*/
void agent_conversations_set_signer(nostr_signer_t *signer,
const char *pubkey_hex);
/*
* List all saved conversations from the local SQLite cache.
*
* Queries kind 30078 events by the user's pubkey that have the
* t-tag "client-ai-chat-v1". Extracts the conversation id
* (from the d-tag, which is just the UUID), title (from the
* decrypted content), and updated_at (from created_at).
*
* Returns a newly allocated cJSON array of summary objects:
* [{"id":"...", "title":"...", "updated_at":N}, ...]
* Returns an empty array on error or if no conversations exist.
* Caller must cJSON_Delete() the result.
*/
cJSON *agent_conversations_list(void);
/*
* Save the current agent chat session to Nostr as a kind 30078 event.
*
* conversation_id — the conversation id (UUID). If NULL, a new UUID is
* generated and the d-tag becomes just "{uuid}"
* (no prefix, matching ai.html).
* title — the conversation title. If NULL, derived from the
* first user message (truncated to 60 chars).
*
* Fetches the current session's messages via agent_chat_store, NIP-44
* encrypts them, builds a kind 30078 event with the d-tag and t-tag,
* signs it, publishes to bootstrap relays, and stores in SQLite.
*
* Returns a newly allocated string with the conversation id (caller must
* g_free), or NULL on error.
*/
char *agent_conversations_save(const char *conversation_id,
const char *title);
/*
* Load a conversation from Nostr (local SQLite cache) into the agent chat
* store as the current session.
*
* conversation_id — the conversation id (the UUID used as the d-tag).
*
* Fetches the kind 30078 event with d-tag "{id}", NIP-44 decrypts
* the content, parses the messages, creates a new agent chat session,
* and populates it with the parsed messages.
*
* Returns 0 on success, -1 on error.
*/
int agent_conversations_load(const char *conversation_id);
/*
* Rename a conversation — update its title in the local SQLite cache
* and re-publish the kind 30078 event with the new title.
*
* conversation_id — the conversation id (the UUID used as the d-tag).
* new_title — the new title (must be non-NULL and non-empty).
*
* Loads the existing event, decrypts the content, replaces the title,
* re-encrypts, signs a new kind 30078 event with the same d-tag, stores
* it in SQLite, and publishes to bootstrap relays.
*
* Returns 0 on success, -1 on error.
*/
int agent_conversations_rename(const char *conversation_id,
const char *new_title);
/*
* Delete a conversation from Nostr.
*
* conversation_id — the conversation id (the UUID used as the d-tag).
*
* Publishes a kind 5 deletion event referencing the kind 30078 event id,
* and removes the event from the local SQLite cache.
*
* Returns 0 on success, -1 on error.
*/
int agent_conversations_delete(const char *conversation_id);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_CONVERSATIONS_H */

488
src/agent_fs_tools.c Normal file
View File

@@ -0,0 +1,488 @@
/*
* agent_fs_tools.c — filesystem & shell tool implementations
*
* Tools:
* fs_read — read a file's text contents
* fs_write — write text to a file (overwrite or create)
* fs_list — list directory entries
* fs_mkdir — create a directory recursively
* fs_delete — delete a file or empty directory
* shell_exec — run a shell command, return stdout+stderr+exit code
*
* The browser runs in a dedicated Qubes OS qube, so full filesystem
* and shell access is intended. These tools work before login.
*/
#include "agent_fs_tools.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <dirent.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <poll.h>
#include <signal.h>
#include <time.h>
#include <glib.h>
/* ── JSON helpers (same pattern as agent_tools.c) ──────────────────── */
static cJSON *make_success(cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
if (data) {
cJSON_AddItemToObject(resp, "data", data);
}
return resp;
}
static cJSON *make_error(const char *code, const char *message) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", message);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static const char *get_string_param(cJSON *params, const char *key) {
return cJSON_GetStringValue(cJSON_GetObjectItem(params, key));
}
static int get_int_param(cJSON *params, const char *key, int fallback) {
cJSON *item = cJSON_GetObjectItem(params, key);
if (item && cJSON_IsNumber(item)) return item->valueint;
return fallback;
}
/* ── fs_read ───────────────────────────────────────────────────────── */
static cJSON *tool_fs_read(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
/* Stat the file first to get the size and verify it's a regular file. */
struct stat st;
if (stat(path, &st) != 0) {
return make_error("FILE_NOT_FOUND",
g_strdup_printf("Cannot stat '%s': %s", path, strerror(errno)));
}
if (!S_ISREG(st.st_mode)) {
return make_error("NOT_A_FILE",
g_strdup_printf("'%s' is not a regular file", path));
}
/* Cap reads at 16 MB to avoid exhausting memory on huge files. */
off_t size = st.st_size;
if (size > (16 * 1024 * 1024)) {
return make_error("FILE_TOO_LARGE",
g_strdup_printf("'%s' is %lld bytes; max read is 16 MB",
path, (long long)size));
}
int fd = open(path, O_RDONLY);
if (fd < 0) {
return make_error("OPEN_FAILED",
g_strdup_printf("Cannot open '%s': %s", path, strerror(errno)));
}
char *buf = g_malloc(size + 1);
if (buf == NULL) {
close(fd);
return make_error("OUT_OF_MEMORY", "Cannot allocate read buffer");
}
off_t total = 0;
while (total < size) {
ssize_t n = read(fd, buf + total, size - total);
if (n < 0) {
if (errno == EINTR) continue;
g_free(buf);
close(fd);
return make_error("READ_FAILED",
g_strdup_printf("Read error on '%s': %s", path, strerror(errno)));
}
if (n == 0) break; /* EOF (file shrank?) */
total += n;
}
buf[total] = '\0';
close(fd);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "content", buf);
g_free(buf);
return make_success(data);
}
/* ── fs_write ──────────────────────────────────────────────────────── */
static cJSON *tool_fs_write(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
const char *content = get_string_param(params, "content");
if (content == NULL) {
/* Allow NULL to be treated as empty string. */
content = "";
}
/* Create parent directories if needed so the write succeeds. */
char *parent = g_path_get_dirname(path);
if (parent && parent[0] && strcmp(parent, ".") != 0) {
g_mkdir_with_parents(parent, 0755);
}
g_free(parent);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
return make_error("OPEN_FAILED",
g_strdup_printf("Cannot open '%s' for writing: %s",
path, strerror(errno)));
}
size_t len = strlen(content);
size_t total = 0;
while (total < len) {
ssize_t n = write(fd, content + total, len - total);
if (n < 0) {
if (errno == EINTR) continue;
close(fd);
return make_error("WRITE_FAILED",
g_strdup_printf("Write error on '%s': %s", path, strerror(errno)));
}
total += (size_t)n;
}
close(fd);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "bytes_written", (double)total);
return make_success(data);
}
/* ── fs_list ───────────────────────────────────────────────────────── */
static cJSON *tool_fs_list(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
DIR *dir = opendir(path);
if (dir == NULL) {
return make_error("OPEN_FAILED",
g_strdup_printf("Cannot open directory '%s': %s",
path, strerror(errno)));
}
cJSON *entries = cJSON_CreateArray();
struct dirent *ent;
while ((ent = readdir(dir)) != NULL) {
/* Skip "." and ".." — they're not useful for an agent. */
if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) {
continue;
}
/* Build the full path to stat the entry. */
char *full = g_strdup_printf("%s/%s", path, ent->d_name);
struct stat st;
const char *type = "file";
long long size = 0;
if (stat(full, &st) == 0) {
if (S_ISDIR(st.st_mode)) {
type = "dir";
} else if (S_ISLNK(st.st_mode)) {
type = "link";
} else if (!S_ISREG(st.st_mode)) {
type = "other";
}
size = (long long)st.st_size;
}
g_free(full);
cJSON *entry = cJSON_CreateObject();
cJSON_AddStringToObject(entry, "name", ent->d_name);
cJSON_AddStringToObject(entry, "type", type);
cJSON_AddNumberToObject(entry, "size", (double)size);
cJSON_AddItemToArray(entries, entry);
}
closedir(dir);
cJSON *data = cJSON_CreateObject();
cJSON_AddItemToObject(data, "entries", entries);
return make_success(data);
}
/* ── fs_mkdir ──────────────────────────────────────────────────────── */
static cJSON *tool_fs_mkdir(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
if (g_mkdir_with_parents(path, 0755) != 0) {
return make_error("MKDIR_FAILED",
g_strdup_printf("Cannot create directory '%s': %s",
path, strerror(errno)));
}
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "created", TRUE);
return make_success(data);
}
/* ── fs_delete ─────────────────────────────────────────────────────── */
static cJSON *tool_fs_delete(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
struct stat st;
if (stat(path, &st) != 0) {
return make_error("FILE_NOT_FOUND",
g_strdup_printf("'%s' does not exist: %s", path, strerror(errno)));
}
int rc;
if (S_ISDIR(st.st_mode)) {
rc = rmdir(path);
} else {
rc = unlink(path);
}
if (rc != 0) {
return make_error("DELETE_FAILED",
g_strdup_printf("Cannot delete '%s': %s", path, strerror(errno)));
}
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "deleted", TRUE);
return make_success(data);
}
/* ── shell_exec ────────────────────────────────────────────────────── *
*
* Runs a shell command via fork() + execvp("/bin/sh", "-c", command),
* capturing stdout and stderr through separate pipes. A timeout is
* enforced with a waitpid() polling loop; if the deadline is exceeded
* the child is killed with SIGKILL and we report exit_code -1.
*/
static cJSON *tool_shell_exec(cJSON *params) {
const char *command = get_string_param(params, "command");
if (!command || !command[0]) {
return make_error("MISSING_PARAM", "Provide 'command'");
}
int timeout_ms = get_int_param(params, "timeout_ms", 30000);
if (timeout_ms < 0) timeout_ms = 30000;
/* Create pipes for stdout and stderr. */
int out_pipe[2];
int err_pipe[2];
if (pipe(out_pipe) != 0) {
return make_error("PIPE_FAILED",
g_strdup_printf("pipe() failed: %s", strerror(errno)));
}
if (pipe(err_pipe) != 0) {
close(out_pipe[0]);
close(out_pipe[1]);
return make_error("PIPE_FAILED",
g_strdup_printf("pipe() failed: %s", strerror(errno)));
}
pid_t pid = fork();
if (pid < 0) {
close(out_pipe[0]); close(out_pipe[1]);
close(err_pipe[0]); close(err_pipe[1]);
return make_error("FORK_FAILED",
g_strdup_printf("fork() failed: %s", strerror(errno)));
}
if (pid == 0) {
/* ── Child ──────────────────────────────────────────────── */
/* Redirect stdout and stderr to the pipes. */
dup2(out_pipe[1], STDOUT_FILENO);
dup2(err_pipe[1], STDERR_FILENO);
close(out_pipe[0]); close(out_pipe[1]);
close(err_pipe[0]); close(err_pipe[1]);
/* Run the command via /bin/sh -c. */
char *const argv[] = {"sh", "-c", (char *)command, NULL};
execvp("/bin/sh", argv);
/* If execvp returns, it failed. */
fprintf(stderr, "execvp failed: %s\n", strerror(errno));
_exit(127);
}
/* ── Parent ──────────────────────────────────────────────────── */
close(out_pipe[1]);
close(err_pipe[1]);
/* Read stdout and stderr fully (non-blocking with poll). */
GString *out_buf = g_string_new(NULL);
GString *err_buf = g_string_new(NULL);
/* Set both pipe read ends to non-blocking. */
fcntl(out_pipe[0], F_SETFL, O_NONBLOCK);
fcntl(err_pipe[0], F_SETFL, O_NONBLOCK);
long deadline_ms = (long)(g_get_monotonic_time() / 1000) + (long)timeout_ms;
int exit_code = -1;
gboolean timed_out = FALSE;
gboolean child_done = FALSE;
char chunk[4096];
while (!child_done) {
/* Check timeout. */
long now_ms = (long)(g_get_monotonic_time() / 1000);
if (now_ms >= deadline_ms) {
timed_out = TRUE;
kill(pid, SIGKILL);
break;
}
/* Poll the pipes for up to 50ms, then check the child. */
struct pollfd pfds[2];
int nfds = 0;
pfds[nfds].fd = out_pipe[0];
pfds[nfds].events = POLLIN;
nfds++;
pfds[nfds].fd = err_pipe[0];
pfds[nfds].events = POLLIN;
nfds++;
int pr = poll(pfds, (nfds_t)nfds, 50);
if (pr > 0) {
for (int i = 0; i < nfds; i++) {
if (pfds[i].revents & POLLIN) {
ssize_t n;
while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) {
if (pfds[i].fd == out_pipe[0]) {
g_string_append_len(out_buf, chunk, n);
} else {
g_string_append_len(err_buf, chunk, n);
}
}
}
if (pfds[i].revents & (POLLHUP | POLLERR)) {
/* Drain remaining, then mark closed. */
ssize_t n;
while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) {
if (pfds[i].fd == out_pipe[0]) {
g_string_append_len(out_buf, chunk, n);
} else {
g_string_append_len(err_buf, chunk, n);
}
}
}
}
}
/* Check if the child has exited. */
int status;
pid_t wr = waitpid(pid, &status, WNOHANG);
if (wr == pid) {
child_done = TRUE;
if (WIFEXITED(status)) {
exit_code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
exit_code = 128 + WTERMSIG(status);
} else {
exit_code = -1;
}
} else if (wr < 0) {
if (errno == EINTR) continue;
/* waitpid error — treat as done. */
child_done = TRUE;
exit_code = -1;
}
}
/* If we broke out due to timeout, reap the child. */
if (timed_out) {
int status;
/* Give it a moment to die from SIGKILL. */
for (int i = 0; i < 10; i++) {
pid_t wr = waitpid(pid, &status, WNOHANG);
if (wr == pid) break;
if (wr < 0 && errno != EINTR) break;
struct timespec ts = {0, 10 * 1000 * 1000}; /* 10ms */
nanosleep(&ts, NULL);
}
/* Final blocking reap. */
waitpid(pid, &status, 0);
exit_code = -1;
}
/* Drain any remaining data from the pipes after the child exited. */
ssize_t n;
while ((n = read(out_pipe[0], chunk, sizeof(chunk))) > 0) {
g_string_append_len(out_buf, chunk, n);
}
while ((n = read(err_pipe[0], chunk, sizeof(chunk))) > 0) {
g_string_append_len(err_buf, chunk, n);
}
close(out_pipe[0]);
close(err_pipe[0]);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "stdout", out_buf->str);
cJSON_AddStringToObject(data, "stderr", err_buf->str);
cJSON_AddNumberToObject(data, "exit_code", exit_code);
if (timed_out) {
cJSON_AddBoolToObject(data, "timed_out", TRUE);
}
g_string_free(out_buf, TRUE);
g_string_free(err_buf, TRUE);
return make_success(data);
}
/* ── Dispatch ──────────────────────────────────────────────────────── */
int agent_fs_is_tool(const char *tool_name) {
if (tool_name == NULL) return 0;
return (strcmp(tool_name, "fs_read") == 0 ||
strcmp(tool_name, "fs_write") == 0 ||
strcmp(tool_name, "fs_list") == 0 ||
strcmp(tool_name, "fs_mkdir") == 0 ||
strcmp(tool_name, "fs_delete") == 0 ||
strcmp(tool_name, "shell_exec") == 0);
}
cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params) {
if (tool_name == NULL) return NULL;
if (params == NULL) params = cJSON_CreateObject();
if (strcmp(tool_name, "fs_read") == 0) {
return tool_fs_read(params);
}
if (strcmp(tool_name, "fs_write") == 0) {
return tool_fs_write(params);
}
if (strcmp(tool_name, "fs_list") == 0) {
return tool_fs_list(params);
}
if (strcmp(tool_name, "fs_mkdir") == 0) {
return tool_fs_mkdir(params);
}
if (strcmp(tool_name, "fs_delete") == 0) {
return tool_fs_delete(params);
}
if (strcmp(tool_name, "shell_exec") == 0) {
return tool_shell_exec(params);
}
return NULL; /* not an fs/shell tool */
}

42
src/agent_fs_tools.h Normal file
View File

@@ -0,0 +1,42 @@
/*
* agent_fs_tools.h — filesystem & shell tools for the embedded agent
*
* Provides tools that give the agent direct filesystem and shell access
* within the browser's qube. These tools work before login (they are
* system-level, not browser-level) and are dispatched early in
* agent_tools_dispatch().
*/
#ifndef AGENT_FS_TOOLS_H
#define AGENT_FS_TOOLS_H
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Dispatch a filesystem/shell tool request.
*
* tool_name: one of "fs_read", "fs_write", "fs_list", "fs_mkdir",
* "fs_delete", "shell_exec"
* params: cJSON object with the tool's parameters
*
* Returns a cJSON response in the same format as agent_tools_dispatch():
* {"success":true,"data":{...}} or
* {"success":false,"error":{"code":"...","message":"..."}}
*
* The caller frees the returned cJSON*. Returns NULL if tool_name is
* not recognized (so the caller can fall through to other handlers).
*/
cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params);
/* Returns TRUE if the given tool name is handled by this module. */
int agent_fs_is_tool(const char *tool_name);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_FS_TOOLS_H */

417
src/agent_llm.c Normal file
View File

@@ -0,0 +1,417 @@
/*
* agent_llm.c — OpenAI-compatible LLM HTTP client
*
* Uses libsoup-3.0's synchronous soup_session_send_and_read() to POST
* a chat-completions request to an OpenAI-compatible endpoint, then
* parses the JSON response with cJSON and returns the assistant's
* message (text content + any tool_calls).
*
* A fresh SoupSession is created per call so this is safe to invoke
* from a background thread (libsoup-3.0 sessions are not meant to be
* shared across threads). The GTK main thread is never touched here.
*/
#include "agent_llm.h"
#include "agent_tool_catalog.h"
#include <libsoup/soup.h>
#include <glib.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* ── OpenAI tools helper ────────────────────────────────────────────── *
*
* build_tools_list() returns the catalog in MCP format:
* [{"name":...,"description":...,"inputSchema":{...}}, ...]
*
* OpenAI wants each entry wrapped as:
* {"type":"function","function":{"name":...,"description":...,"parameters":{...}}}
*
* We rebuild from tool_defs[] directly so the "parameters" field is a
* fresh cJSON copy (the MCP version uses "inputSchema").
*/
cJSON *agent_llm_build_openai_tools(void) {
cJSON *tools = cJSON_CreateArray();
if (tools == NULL) {
return NULL;
}
for (int i = 0; i < tool_defs_count; i++) {
cJSON *entry = cJSON_CreateObject();
if (entry == NULL) {
cJSON_Delete(tools);
return NULL;
}
cJSON_AddStringToObject(entry, "type", "function");
cJSON *fn = cJSON_CreateObject();
if (fn == NULL) {
cJSON_Delete(entry);
cJSON_Delete(tools);
return NULL;
}
cJSON_AddStringToObject(fn, "name", tool_defs[i].name);
cJSON_AddStringToObject(fn, "description", tool_defs[i].description);
cJSON *schema = cJSON_Parse(tool_defs[i].schema_json);
if (schema) {
cJSON_AddItemToObject(fn, "parameters", schema);
} else {
/* Fall back to an empty object so the field is always present. */
cJSON_AddItemToObject(fn, "parameters", cJSON_CreateObject());
}
cJSON_AddItemToObject(entry, "function", fn);
cJSON_AddItemToArray(tools, entry);
}
return tools;
}
/* ── Response lifecycle ─────────────────────────────────────────────── */
void agent_llm_response_free(agent_llm_response_t *resp) {
if (resp == NULL) {
return;
}
g_free(resp->content);
if (resp->tool_calls) {
cJSON_Delete(resp->tool_calls);
}
g_free(resp->finish_reason);
g_free(resp);
}
/* ── Internal: build the request body JSON ──────────────────────────── *
*
* Returns a newly-allocated JSON string (caller frees with g_free).
* The caller's messages/tools arrays are referenced (not consumed) —
* cJSON_AddItemReferenceToObject increments the refcount so the caller
* retains ownership.
*
* Returns NULL on allocation failure.
*/
static char *build_request_body(const char *model,
cJSON *messages,
cJSON *tools) {
cJSON *root = cJSON_CreateObject();
if (root == NULL) {
return NULL;
}
cJSON_AddStringToObject(root, "model", model);
/* Reference the caller's array so we don't steal ownership. */
cJSON_AddItemReferenceToObject(root, "messages", messages);
if (tools != NULL) {
cJSON_AddItemReferenceToObject(root, "tools", tools);
cJSON_AddStringToObject(root, "tool_choice", "auto");
}
char *str = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return str;
}
/* ── Internal: parse the response body into agent_llm_response_t ────── *
*
* body is the raw HTTP response body (NUL-terminated by caller).
* Returns a newly-allocated agent_llm_response_t, or NULL on parse
* failure / error response.
*/
static agent_llm_response_t *parse_response(const char *body) {
cJSON *root = cJSON_Parse(body);
if (root == NULL) {
g_printerr("[agent_llm] failed to parse response JSON\n");
return NULL;
}
/* Some servers return {"error": {...}} on failure. */
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
if (err) {
char *err_str = cJSON_PrintUnformatted(err);
g_printerr("[agent_llm] API error: %s\n",
err_str ? err_str : "(unknown)");
cJSON_free(err_str);
cJSON_Delete(root);
return NULL;
}
cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
cJSON *choice0 = cJSON_GetArrayItem(choices, 0);
if (choice0 == NULL) {
g_printerr("[agent_llm] response has no choices[0]\n");
cJSON_Delete(root);
return NULL;
}
agent_llm_response_t *resp = g_new0(agent_llm_response_t, 1);
if (resp == NULL) {
cJSON_Delete(root);
return NULL;
}
cJSON *message = cJSON_GetObjectItemCaseSensitive(choice0, "message");
if (message) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(message, "content");
if (cJSON_IsString(content) && content->valuestring != NULL) {
resp->content = g_strdup(content->valuestring);
} else {
resp->content = NULL;
}
cJSON *tool_calls = cJSON_GetObjectItemCaseSensitive(message, "tool_calls");
if (tool_calls != NULL) {
/* Detach a deep copy so the response outlives the parsed root. */
resp->tool_calls = cJSON_Duplicate(tool_calls, 1);
} else {
resp->tool_calls = NULL;
}
}
cJSON *finish = cJSON_GetObjectItemCaseSensitive(choice0, "finish_reason");
if (cJSON_IsString(finish) && finish->valuestring != NULL) {
resp->finish_reason = g_strdup(finish->valuestring);
} else {
resp->finish_reason = NULL;
}
cJSON_Delete(root);
return resp;
}
/* ── Internal: normalize base URL ───────────────────────────────────── *
*
* Many OpenAI-compatible APIs expect the path prefix "/v1" before the
* endpoint (e.g. https://api.openai.com/v1/models). If the user supplies
* a base URL that already ends with "/v1" (or another "/vN" version
* segment) we leave it alone; otherwise we append "/v1" so that
* {base}/models and {base}/chat/completions resolve correctly.
*
* Returns a newly-allocated string (caller frees with g_free).
*/
static char *normalize_base_url(const char *base_url) {
if (base_url == NULL) {
return NULL;
}
/* Strip trailing slashes for consistent checking. */
g_autofree char *trimmed = NULL;
{
size_t len = strlen(base_url);
while (len > 0 && base_url[len - 1] == '/') {
len--;
}
trimmed = g_strndup(base_url, len);
}
size_t tlen = strlen(trimmed);
/* Already ends with "/v1"? */
if (tlen >= 3 && strcmp(trimmed + tlen - 3, "/v1") == 0) {
return g_strdup(trimmed);
}
/* Also handle "/v2", "/v3" etc. — if the last path segment is
* "v" followed by one or more digits, assume the user already
* included a version prefix. */
if (tlen > 0) {
const char *slash = strrchr(trimmed, '/');
if (slash != NULL) {
const char *seg = slash + 1;
if (seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9') {
return g_strdup(trimmed);
}
}
}
return g_strdup_printf("%s/v1", trimmed);
}
/* ── Public: agent_llm_chat ─────────────────────────────────────────── */
agent_llm_response_t *agent_llm_chat(const char *base_url,
const char *api_key,
const char *model,
cJSON *messages,
cJSON *tools) {
if (base_url == NULL || model == NULL || messages == NULL) {
return NULL;
}
/* Build the full URL: {normalized_base_url}/chat/completions.
* normalize_base_url() strips trailing slashes and appends "/v1"
* if needed, so we never produce a "//" here. */
g_autofree char *norm = normalize_base_url(base_url);
g_autofree char *url = g_strdup_printf("%s/chat/completions", norm);
/* Build request body. */
g_autofree char *body = build_request_body(model, messages, tools);
if (body == NULL) {
g_printerr("[agent_llm] failed to build request body\n");
return NULL;
}
/* Create the SoupMessage. */
SoupMessage *msg = soup_message_new("POST", url);
if (msg == NULL) {
g_printerr("[agent_llm] invalid URL: %s\n", url);
return NULL;
}
soup_message_headers_set_content_type(
soup_message_get_request_headers(msg), "application/json", NULL);
if (api_key != NULL && api_key[0] != '\0') {
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
soup_message_headers_append(
soup_message_get_request_headers(msg), "Authorization", bearer);
}
/* Set the request body from a GBytes. libsoup-3.0 takes ownership
* of the GBytes; the g_autofree `body` string is freed at scope exit. */
GBytes *req_bytes = g_bytes_new(body, strlen(body));
soup_message_set_request_body_from_bytes(msg, "application/json", req_bytes);
g_bytes_unref(req_bytes);
/* Send synchronously. A fresh session per call keeps things
* thread-safe without any global state. */
g_print("[agent_llm] POST %s (model=%s, %d messages, %d tools)\n",
url, model, cJSON_GetArraySize(messages),
tools ? cJSON_GetArraySize(tools) : 0);
SoupSession *session = soup_session_new();
/* Set a generous timeout (300s) to accommodate slow CPU-only models
* and cold-start loading. The default libsoup timeout is 60s, which
* is too short for large local models. */
g_object_set(session, "timeout", 300, NULL);
GError *error = NULL;
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
agent_llm_response_t *result = NULL;
if (error != NULL) {
g_printerr("[agent_llm] HTTP transport error: %s\n",
error->message);
g_error_free(error);
} else {
guint status = soup_message_get_status(msg);
if (status != SOUP_STATUS_OK) {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_printerr("[agent_llm] HTTP %u: %.*s\n",
status, (int)size, data ? data : "");
} else {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
/* Make a NUL-terminated copy for cJSON. */
g_autofree char *body_str = g_strndup(data ? data : "", size);
result = parse_response(body_str);
}
}
if (resp_bytes) {
g_bytes_unref(resp_bytes);
}
g_object_unref(msg);
g_object_unref(session);
return result;
}
/* ── Public: agent_llm_list_models ──────────────────────────────────── *
*
* Fetch the list of available models from an OpenAI-compatible API.
* Calls GET {base_url}/models with an Authorization: Bearer header
* (if api_key is non-NULL and non-empty). Parses the "data" array and
* returns a cJSON array of model ID strings. Returns NULL on error.
* Caller must cJSON_Delete() the returned array.
*/
cJSON *agent_llm_list_models(const char *base_url, const char *api_key) {
if (base_url == NULL || base_url[0] == '\0') {
return NULL;
}
/* Build the full URL: {normalized_base_url}/models.
* normalize_base_url() strips trailing slashes and appends "/v1"
* if needed, so we never produce a "//" here. */
g_autofree char *norm = normalize_base_url(base_url);
g_autofree char *url = g_strdup_printf("%s/models", norm);
SoupMessage *msg = soup_message_new("GET", url);
if (msg == NULL) {
g_printerr("[agent_llm] invalid URL: %s\n", url);
return NULL;
}
if (api_key != NULL && api_key[0] != '\0') {
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
soup_message_headers_append(
soup_message_get_request_headers(msg), "Authorization", bearer);
}
SoupSession *session = soup_session_new();
GError *error = NULL;
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
cJSON *models = NULL;
if (error != NULL) {
g_printerr("[agent_llm] list_models transport error: %s\n",
error->message);
g_error_free(error);
} else {
guint status = soup_message_get_status(msg);
if (status != SOUP_STATUS_OK) {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_printerr("[agent_llm] list_models HTTP %u: %.*s\n",
status, (int)size, data ? data : "");
} else {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_autofree char *body_str = g_strndup(data ? data : "", size);
cJSON *root = cJSON_Parse(body_str);
if (root == NULL) {
g_printerr("[agent_llm] list_models: failed to parse JSON\n");
} else {
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
if (err) {
g_printerr("[agent_llm] list_models: API returned error\n");
} else {
cJSON *data_arr = cJSON_GetObjectItemCaseSensitive(root, "data");
if (cJSON_IsArray(data_arr)) {
models = cJSON_CreateArray();
if (models != NULL) {
cJSON *entry;
cJSON_ArrayForEach(entry, data_arr) {
cJSON *id = cJSON_GetObjectItemCaseSensitive(entry, "id");
if (cJSON_IsString(id) && id->valuestring != NULL) {
cJSON_AddItemToArray(models,
cJSON_CreateString(id->valuestring));
}
}
/* If no IDs were found, treat as error. */
if (cJSON_GetArraySize(models) == 0) {
cJSON_Delete(models);
models = NULL;
}
}
}
}
cJSON_Delete(root);
}
}
}
if (resp_bytes) {
g_bytes_unref(resp_bytes);
}
g_object_unref(msg);
g_object_unref(session);
return models;
}

86
src/agent_llm.h Normal file
View File

@@ -0,0 +1,86 @@
/*
* agent_llm.h — OpenAI-compatible LLM HTTP client
*
* Sends chat-completions requests to an OpenAI-compatible endpoint
* (OpenAI, OpenRouter, Ollama, LM Studio, Groq, etc.) using libsoup-3.0,
* parses the JSON response, and returns the assistant's message (text
* content + any tool_calls).
*
* The HTTP call is synchronous (soup_session_send_and_read) and is
* intended to be called from a background thread — not the GTK main
* thread. A fresh SoupSession is created per call so there are no
* cross-thread sharing issues.
*/
#ifndef AGENT_LLM_H
#define AGENT_LLM_H
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── Response ──────────────────────────────────────────────────────── */
typedef struct {
char *content; /* assistant text (may be NULL or empty) */
cJSON *tool_calls; /* JSON array of tool call objects, or NULL */
char *finish_reason; /* "stop", "tool_calls", "length", etc. */
} agent_llm_response_t;
/*
* Call an OpenAI-compatible chat completions endpoint.
*
* base_url — e.g. "https://api.openai.com/v1" or "http://localhost:11434/v1"
* api_key — bearer token (may be NULL for local servers like Ollama)
* model — model name, e.g. "gpt-4o", "llama3.1", etc.
* messages — cJSON array of message objects (role/content/tool_calls/tool_call_id)
* tools — cJSON array of tool definitions (OpenAI format), or NULL
*
* Returns a newly-allocated agent_llm_response_t. Caller must free with
* agent_llm_response_free().
*
* On error, returns NULL (caller should handle gracefully).
*/
agent_llm_response_t *agent_llm_chat(const char *base_url,
const char *api_key,
const char *model,
cJSON *messages,
cJSON *tools);
/*
* Free an agent_llm_response_t returned by agent_llm_chat().
* Safe to call with NULL.
*/
void agent_llm_response_free(agent_llm_response_t *resp);
/*
* Build the OpenAI-format "tools" array from the shared tool catalog
* (agent_tool_catalog.h). Each entry is wrapped as:
*
* {"type":"function","function":{"name":...,"description":...,"parameters":{...}}}
*
* Returns a newly-allocated cJSON array. Caller frees with cJSON_Delete().
* Returns NULL if the catalog is empty or on allocation failure.
*/
cJSON *agent_llm_build_openai_tools(void);
/*
* Fetch the list of available models from an OpenAI-compatible API.
* Calls GET {base_url}/models with the Authorization header.
*
* base_url — e.g. "https://api.ppq.ai"
* api_key — bearer token (may be NULL for local servers)
*
* Returns a cJSON array of model ID strings (newly allocated), e.g.:
* ["gpt-4o", "gpt-4o-mini", "llama3.1"]
* Returns NULL on error. Caller must cJSON_Delete().
*/
cJSON *agent_llm_list_models(const char *base_url, const char *api_key);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_LLM_H */

484
src/agent_loop.c Normal file
View File

@@ -0,0 +1,484 @@
/*
* agent_loop.c — ReAct tool-call loop on a background GThread
*
* Implements the standard ReAct loop:
* 1. Load chat history from agent_chat_store_get_messages().
* 2. Prepend a system prompt message (must be first in the array).
* 3. Call the LLM via agent_llm_chat().
* 4. If the response contains tool_calls, dispatch each:
* - Browser tools → hop to the GTK main thread via g_idle_add() +
* GAsyncQueue (WebKitGTK is not thread-safe).
* - Filesystem/shell tools → run directly on this thread via
* agent_fs_tools_dispatch().
* 5. Append tool results to the chat store.
* 6. Repeat until no more tool_calls, the iteration cap is reached, or
* the cancel flag is set.
* 7. Persist the final assistant message and update session status.
*
* See plans/embedded-agent.md for the full concurrency model.
*/
#include "agent_loop.h"
#include "agent_llm.h"
#include "agent_chat_store.h"
#include "agent_fs_tools.h"
#include "agent_tools.h"
#include "agent_skills.h"
#include "agent_server.h"
#include "settings.h"
#include "cjson/cJSON.h"
#include <glib.h>
#include <string.h>
/* ── Context ────────────────────────────────────────────────────────── */
typedef struct {
/* Atomic flags */
volatile gint cancel_flag;
volatile gint running; /* gboolean as gint */
/* Status (protected by status_lock) */
GMutex status_lock;
agent_loop_state_t state;
int iteration;
char *current_tool; /* tool name being executed */
char *last_message; /* last assistant text */
char *error; /* error message */
/* Thread handle */
GThread *thread;
} agent_loop_ctx_t;
static agent_loop_ctx_t g_ctx = {0};
/* ── Status helpers ─────────────────────────────────────────────────── */
/* Map a state enum to its string name for JSON events. */
static const char *
state_name(agent_loop_state_t s)
{
switch (s) {
case AGENT_LOOP_IDLE: return "idle";
case AGENT_LOOP_THINKING: return "thinking";
case AGENT_LOOP_TOOL_CALL: return "tool_call";
case AGENT_LOOP_COMPLETE: return "complete";
case AGENT_LOOP_ERROR: return "error";
case AGENT_LOOP_CANCELLED: return "cancelled";
}
return "idle";
}
/* Emit the current status as a WebSocket push event so the chat UI
* can update instantly without polling. Must be called WITHOUT the
* mutex held (it takes it itself). */
static void
emit_status_event(void)
{
g_mutex_lock(&g_ctx.status_lock);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "state", state_name(g_ctx.state));
cJSON_AddNumberToObject(data, "iteration", g_ctx.iteration);
if (g_ctx.current_tool)
cJSON_AddStringToObject(data, "current_tool", g_ctx.current_tool);
else
cJSON_AddNullToObject(data, "current_tool");
if (g_ctx.last_message)
cJSON_AddStringToObject(data, "last_message", g_ctx.last_message);
else
cJSON_AddNullToObject(data, "last_message");
if (g_ctx.error)
cJSON_AddStringToObject(data, "error", g_ctx.error);
else
cJSON_AddNullToObject(data, "error");
g_mutex_unlock(&g_ctx.status_lock);
/* agent_server_emit_event takes ownership of data. */
agent_server_emit_event("agent_status", data);
}
static void
set_status(agent_loop_state_t state, int iter,
const char *tool, const char *msg, const char *err)
{
g_mutex_lock(&g_ctx.status_lock);
g_ctx.state = state;
g_ctx.iteration = iter;
if (tool) {
g_free(g_ctx.current_tool);
g_ctx.current_tool = g_strdup(tool);
} else {
/* Clear tool name when not in a tool-call state. */
g_free(g_ctx.current_tool);
g_ctx.current_tool = NULL;
}
if (msg) {
g_free(g_ctx.last_message);
g_ctx.last_message = g_strdup(msg);
}
/* Only set the error field when err is non-NULL. When err is NULL
* and we're transitioning to a non-error state, clear any stale
* error so the UI doesn't show an old error message. */
if (err) {
g_free(g_ctx.error);
g_ctx.error = g_strdup(err);
} else if (state != AGENT_LOOP_ERROR) {
g_free(g_ctx.error);
g_ctx.error = NULL;
}
g_mutex_unlock(&g_ctx.status_lock);
emit_status_event();
}
static void
set_state(agent_loop_state_t state)
{
g_mutex_lock(&g_ctx.status_lock);
g_ctx.state = state;
/* Clear stale error when transitioning to a non-error state. */
if (state != AGENT_LOOP_ERROR) {
g_free(g_ctx.error);
g_ctx.error = NULL;
}
/* Clear tool name when leaving the tool_call state. */
if (state != AGENT_LOOP_TOOL_CALL) {
g_free(g_ctx.current_tool);
g_ctx.current_tool = NULL;
}
g_mutex_unlock(&g_ctx.status_lock);
emit_status_event();
}
static void
set_error(const char *msg)
{
g_mutex_lock(&g_ctx.status_lock);
g_ctx.state = AGENT_LOOP_ERROR;
g_free(g_ctx.error);
g_ctx.error = g_strdup(msg);
g_mutex_unlock(&g_ctx.status_lock);
/* Emit a status event so the UI learns about the error immediately.
* Without this, the chat page stays stuck on "Thinking..." because
* the WebSocket push never fires and the polling fallback may have
* been stopped when the WebSocket connected. */
emit_status_event();
}
/* ── Build messages array with system prompt first ──────────────────── */
/*
* Build the messages array for the LLM: a system message first, then
* all messages from the chat store. Returns a newly-allocated cJSON
* array (caller frees with cJSON_Delete), or NULL on error.
*/
static cJSON *
build_messages_with_system(const char *system_prompt)
{
cJSON *history = agent_chat_store_get_messages();
if (history == NULL)
return NULL;
cJSON *messages = cJSON_CreateArray();
if (messages == NULL) {
cJSON_Delete(history);
return NULL;
}
/* System prompt must be the first message */
cJSON *sys_msg = cJSON_CreateObject();
cJSON_AddStringToObject(sys_msg, "role", "system");
cJSON_AddStringToObject(sys_msg, "content", system_prompt);
cJSON_AddItemToArray(messages, sys_msg);
/* Copy all history messages into the new array */
cJSON *msg;
cJSON_ArrayForEach(msg, history) {
cJSON_AddItemToArray(messages, cJSON_Duplicate(msg, 1));
}
cJSON_Delete(history);
return messages;
}
/* ── Background thread ──────────────────────────────────────────────── */
static gpointer
agent_loop_thread(gpointer data)
{
char *user_message = (char *)data;
g_print("[agent-loop] Thread started, message: %s\n", user_message);
/* 1. Add user message to chat store */
agent_chat_store_add_user_message(user_message);
g_free(user_message);
/* Notify UI that a message was added. */
cJSON *umsg = cJSON_CreateObject();
cJSON_AddStringToObject(umsg, "role", "user");
agent_server_emit_event("agent_message", umsg);
/* 2. Get provider settings (copy — they could change while running) */
const browser_settings_t *s = settings_get();
char base_url[512], api_key[512], model[128];
g_strlcpy(base_url, s->agent_llm_base_url, sizeof(base_url));
g_strlcpy(api_key, s->agent_llm_api_key, sizeof(api_key));
g_strlcpy(model, s->agent_llm_model, sizeof(model));
g_print("[agent-loop] base_url=%s model=%s api_key=%s max_iter=%d\n",
base_url, model, api_key[0] ? "(set)" : "(empty)", s->agent_max_iterations);
/* Build the system prompt. agent_skills_build_system_prompt() now
* always returns a non-NULL string: it starts with the Sovereign
* Browser Skill template (from settings) as the base, then
* appends any selected Nostr skills' templates. */
char *system_prompt = agent_skills_build_system_prompt();
if (system_prompt == NULL) {
/* Defensive fallback — should never happen. */
system_prompt = g_strdup(SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
}
g_print("[agent-loop] Using system prompt (%zu bytes)\n",
strlen(system_prompt));
int max_iter = s->agent_max_iterations;
if (max_iter <= 0)
max_iter = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT;
/* Empty API key → pass NULL (local servers like Ollama don't need one) */
const char *key_arg = (api_key[0] != '\0') ? api_key : NULL;
/* 3. Build OpenAI tools array (built once, reused each iteration) */
cJSON *tools = agent_llm_build_openai_tools();
/* 4. ReAct loop */
for (int iter = 0; iter < max_iter; iter++) {
/* Check cancel */
if (g_atomic_int_get(&g_ctx.cancel_flag)) {
set_state(AGENT_LOOP_CANCELLED);
break;
}
/* Update status: thinking */
set_status(AGENT_LOOP_THINKING, iter, NULL, NULL, NULL);
/* Build messages: system prompt first, then chat history */
cJSON *messages = build_messages_with_system(system_prompt);
if (messages == NULL) {
set_error("Failed to load messages");
break;
}
/* Call LLM (blocking HTTP on this thread) */
g_print("[agent-loop] iter %d: calling LLM...\n", iter);
agent_llm_response_t *resp = agent_llm_chat(base_url, key_arg,
model, messages, tools);
cJSON_Delete(messages);
if (resp == NULL) {
g_print("[agent-loop] iter %d: LLM call returned NULL\n", iter);
set_error("LLM API call failed");
break;
}
g_print("[agent-loop] iter %d: LLM responded, finish=%s, content=%s, tool_calls=%d\n",
iter, resp->finish_reason ? resp->finish_reason : "(null)",
resp->content ? resp->content : "(null)",
resp->tool_calls ? cJSON_GetArraySize(resp->tool_calls) : 0);
/* Persist assistant message */
char *tool_calls_str = (resp->tool_calls != NULL)
? cJSON_PrintUnformatted(resp->tool_calls) : NULL;
agent_chat_store_add_assistant_message(resp->content, tool_calls_str);
g_free(tool_calls_str);
/* Notify UI that a message was added. */
cJSON *amsg = cJSON_CreateObject();
cJSON_AddStringToObject(amsg, "role", "assistant");
agent_server_emit_event("agent_message", amsg);
/* Update last_message status */
set_status(AGENT_LOOP_THINKING, iter, NULL,
resp->content ? resp->content : "", NULL);
/* If no tool_calls, we're done */
if (resp->tool_calls == NULL ||
cJSON_GetArraySize(resp->tool_calls) == 0) {
agent_llm_response_free(resp);
set_state(AGENT_LOOP_COMPLETE);
goto done;
}
/* Dispatch each tool call */
cJSON *tc;
cJSON_ArrayForEach(tc, resp->tool_calls) {
if (g_atomic_int_get(&g_ctx.cancel_flag))
break;
cJSON *fn = cJSON_GetObjectItem(tc, "function");
const char *tool_name = fn
? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "name"))
: NULL;
const char *args_str = fn
? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "arguments"))
: NULL;
const char *tc_id = cJSON_GetStringValue(cJSON_GetObjectItem(tc, "id"));
cJSON *args = (args_str != NULL)
? cJSON_Parse(args_str) : cJSON_CreateObject();
if (args == NULL)
args = cJSON_CreateObject();
set_status(AGENT_LOOP_TOOL_CALL, iter,
tool_name ? tool_name : "?", NULL, NULL);
g_print("[agent-loop] iter %d: tool %s (args: %s)\n",
iter, tool_name ? tool_name : "?",
args_str ? args_str : "(null)");
/* Dispatch: fs/shell tools and browser tools both run on
* this background thread. The browser tools use the same
* sync JS eval path (conn=NULL) as the MCP HTTP handler,
* which calls gtk_main_iteration() to pump the main loop
* while waiting for the async JS result. This is safe to
* call from a background thread — the main loop keeps
* running and processes the JS eval callback. */
cJSON *result;
if (tool_name != NULL && agent_fs_is_tool(tool_name)) {
result = agent_fs_tools_dispatch(tool_name, args);
} else {
/* Build the request JSON and dispatch directly (same
* as the MCP HTTP handler does from the libsoup thread). */
cJSON *request = cJSON_CreateObject();
cJSON_AddStringToObject(request, "tool",
tool_name ? tool_name : "");
cJSON_AddItemToObject(request, "params", args);
args = NULL; /* transferred to request */
result = agent_tools_dispatch(request, NULL);
cJSON_Delete(request);
}
if (args) cJSON_Delete(args);
/* Get result JSON string */
char *result_str = (result != NULL)
? cJSON_PrintUnformatted(result) : g_strdup("{}");
cJSON_Delete(result);
/* Persist tool result */
agent_chat_store_add_tool_result(
tc_id ? tc_id : "", result_str ? result_str : "{}");
g_free(result_str);
/* Notify UI that a tool result was added. */
cJSON *tmsg = cJSON_CreateObject();
cJSON_AddStringToObject(tmsg, "role", "tool");
agent_server_emit_event("agent_message", tmsg);
}
agent_llm_response_free(resp);
}
/* If we exited the loop without completing or erroring, we hit the
* iteration cap — treat as complete (the LLM was still working). */
{
g_mutex_lock(&g_ctx.status_lock);
if (g_ctx.state != AGENT_LOOP_CANCELLED &&
g_ctx.state != AGENT_LOOP_ERROR) {
g_ctx.state = AGENT_LOOP_COMPLETE;
}
g_mutex_unlock(&g_ctx.status_lock);
}
done:
g_free(system_prompt);
cJSON_Delete(tools);
g_atomic_int_set(&g_ctx.running, 0);
return NULL;
}
/* ── Public API ─────────────────────────────────────────────────────── */
/*
* Lazily initialize the mutex on first use. g_mutex_init() is idempotent
* enough for our purposes — we guard with a g_once_init_enter block so
* it only happens once.
*/
static void
ensure_mutex_init(void)
{
static gsize initialized = 0;
if (g_once_init_enter(&initialized)) {
g_mutex_init(&g_ctx.status_lock);
g_ctx.state = AGENT_LOOP_IDLE;
g_once_init_leave(&initialized, 1);
}
}
int
agent_loop_run(const char *user_message)
{
g_print("[agent-loop] agent_loop_run called: %s\n",
user_message ? user_message : "(null)");
if (user_message == NULL)
return -1;
ensure_mutex_init();
if (g_atomic_int_get(&g_ctx.running)) {
g_print("[agent-loop] already running, rejecting\n");
return -1; /* already running */
}
/* Check that a provider is configured (base URL + model).
* An empty API key is allowed (local servers like Ollama). */
const browser_settings_t *s = settings_get();
if (s->agent_llm_base_url[0] == '\0' || s->agent_llm_model[0] == '\0') {
g_print("[agent-loop] no provider configured: base_url=%s model=%s\n",
s->agent_llm_base_url, s->agent_llm_model);
set_error("No LLM provider configured (set base URL and model on "
"sovereign://agents)");
return -1;
}
/* Reset cancel flag */
g_atomic_int_set(&g_ctx.cancel_flag, 0);
/* Reset status */
set_status(AGENT_LOOP_THINKING, 0, NULL, NULL, NULL);
g_atomic_int_set(&g_ctx.running, 1);
/* Spawn background thread (takes ownership of the duplicated string) */
g_ctx.thread = g_thread_new("agent-loop", agent_loop_thread,
g_strdup(user_message));
return 0;
}
void
agent_loop_cancel(void)
{
g_atomic_int_set(&g_ctx.cancel_flag, 1);
}
gboolean
agent_loop_is_running(void)
{
return g_atomic_int_get(&g_ctx.running) != 0;
}
void
agent_loop_get_status(agent_loop_state_t *state_out,
int *iteration_out,
char **current_tool_out,
char **last_message_out,
char **error_out)
{
ensure_mutex_init();
g_mutex_lock(&g_ctx.status_lock);
if (state_out)
*state_out = g_ctx.state;
if (iteration_out)
*iteration_out = g_ctx.iteration;
if (current_tool_out)
*current_tool_out = g_strdup(g_ctx.current_tool);
if (last_message_out)
*last_message_out = g_strdup(g_ctx.last_message);
if (error_out)
*error_out = g_strdup(g_ctx.error);
g_mutex_unlock(&g_ctx.status_lock);
}

81
src/agent_loop.h Normal file
View File

@@ -0,0 +1,81 @@
/*
* agent_loop.h — ReAct tool-call loop for the embedded agent
*
* Runs the standard ReAct (reason → act → observe) loop on a background
* GThread so the GTK main thread stays responsive during long LLM calls
* and multi-step tool sequences.
*
* Threading model (see plans/embedded-agent.md):
* - LLM HTTP calls block on the background thread.
* - Browser tools (snapshot, click, eval, …) hop to the GTK main thread
* via g_idle_add() + GAsyncQueue because WebKitGTK is not thread-safe.
* - Filesystem/shell tools run directly on the background thread.
* - SQLite writes use the FULLMUTEX connection and are safe from any thread.
* - Cancellation is via an atomic flag checked at the top of each iteration.
*/
#ifndef AGENT_LOOP_H
#define AGENT_LOOP_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Status states for the agent loop.
*/
typedef enum {
AGENT_LOOP_IDLE,
AGENT_LOOP_THINKING,
AGENT_LOOP_TOOL_CALL,
AGENT_LOOP_COMPLETE,
AGENT_LOOP_ERROR,
AGENT_LOOP_CANCELLED
} agent_loop_state_t;
/*
* Start the agent loop for a user message. This:
* 1. Adds the user message to the chat store.
* 2. Spawns a background GThread that runs the ReAct loop.
* 3. Returns immediately (non-blocking).
*
* Returns 0 on success, -1 on error (e.g. already running, no provider
* configured).
*/
int agent_loop_run(const char *user_message);
/*
* Request cancellation of the running agent loop.
* Sets an atomic flag checked at the top of each loop iteration.
* The background thread will exit at the next check point.
*/
void agent_loop_cancel(void);
/*
* Check if the agent loop is currently running.
*/
gboolean agent_loop_is_running(void);
/*
* Get the current status of the agent loop. Returns the state and
* fills the optional output parameters with current status info.
*
* state_out — current state (may be NULL)
* iteration_out — current iteration number (may be NULL)
* current_tool_out — name of tool being executed (may be NULL, caller g_free)
* last_message_out — most recent assistant text (may be NULL, caller g_free)
* error_out — error message if state is ERROR (may be NULL, caller g_free)
*/
void agent_loop_get_status(agent_loop_state_t *state_out,
int *iteration_out,
char **current_tool_out,
char **last_message_out,
char **error_out);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_LOOP_H */

View File

@@ -15,6 +15,7 @@
#include "agent_tools.h"
#include "agent_snapshot.h"
#include "agent_login.h"
#include "agent_tool_catalog.h"
#include "tab_manager.h"
#include "version.h"
@@ -117,17 +118,13 @@ static cJSON *rpc_error(int id, int code, const char *message) {
}
/* ── Tool catalog ─────────────────────────────────────────────────── *
* Static array of tool definitions. Each has a name, description,
* and JSON Schema for input parameters.
* Array of tool definitions. Each has a name, description, and JSON
* Schema for input parameters. The mcp_tool_def_t struct, the array,
* and build_tools_list() are declared in agent_tool_catalog.h so they
* can be shared with the embedded agent (agent_llm.c).
*/
typedef struct {
const char *name;
const char *description;
const char *schema_json; /* pre-built JSON schema string */
} mcp_tool_def_t;
static mcp_tool_def_t tool_defs[] = {
const mcp_tool_def_t tool_defs[] = {
/* Login tools */
{"login_status",
"Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
@@ -548,13 +545,38 @@ static mcp_tool_def_t tool_defs[] = {
{"screenshot_annotated",
"Take a screenshot with element ref labels overlaid on the page. Combines snapshot and screenshot — returns both an image and the text accessibility tree.",
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true},\"compact\":{\"type\":\"boolean\",\"default\":true}}}"},
/* Filesystem & shell tools (work before login — system-level) */
{"fs_read",
"Read a file's text contents from the local filesystem. The browser runs in a dedicated qube, so full access is intended. Returns the file content as a string.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute or relative path to the file\"}},\"required\":[\"path\"]}"},
{"fs_write",
"Write text to a file on the local filesystem. Overwrites if the file exists, creates it (and parent directories) if it doesn't. Returns the number of bytes written.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file\"},\"content\":{\"type\":\"string\",\"description\":\"Text content to write\"}},\"required\":[\"path\",\"content\"]}"},
{"fs_list",
"List directory entries (files and subdirectories). Returns each entry's name, type (file/dir/link/other), and size in bytes.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory\"}},\"required\":[\"path\"]}"},
{"fs_mkdir",
"Create a directory, including parent directories if needed (recursive, like mkdir -p).",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory to create\"}},\"required\":[\"path\"]}"},
{"fs_delete",
"Delete a file or an empty directory. Non-empty directories must be removed with shell_exec (e.g. rm -rf).",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file or empty directory\"}},\"required\":[\"path\"]}"},
{"shell_exec",
"Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.",
"{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"},
};
static int tool_defs_count = sizeof(tool_defs) / sizeof(tool_defs[0]);
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));
/* ── Build tools/list response ────────────────────────────────────── */
static cJSON *build_tools_list(void) {
cJSON *build_tools_list(void) {
cJSON *tools = cJSON_CreateArray();
for (int i = 0; i < tool_defs_count; i++) {
cJSON *tool = cJSON_CreateObject();

833
src/agent_skills.c Normal file
View File

@@ -0,0 +1,833 @@
/*
* agent_skills.c — Nostr kind 31123 skill management for sovereign_browser
*
* Implements fetch, selection, system-prompt building, publish, and delete
* for kind 31123 skill events. Skills are PUBLIC events (no NIP-44
* encryption) authored by any user. sovereign_browser fetches them from
* the local SQLite cache (populated by the relay fetch thread on startup)
* and lets users select skills whose templates are concatenated into the
* system prompt.
*
* Reuses the event signing + relay publishing patterns from
* settings_sync.c, bookmarks.c, and agent_conversations.c.
*
* See plans/cross-project-agent-sync.md (Phase C/D) for the full design.
*/
#include "agent_skills.h"
#include "db.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global state ───────────────────────────────────────────────────── */
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static int g_have_signer = 0;
/* ── Init ───────────────────────────────────────────────────────────── */
void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
} else {
g_pubkey[0] = '\0';
}
}
void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
agent_skills_init(signer, pubkey_hex);
}
/* ── Helpers ────────────────────────────────────────────────────────── */
/* Parse bootstrap relays from settings into an array.
* Returns the count. Fills urls_out (pointers into relay_buf).
* Caller must g_free(relay_buf) after use. (Same pattern as
* settings_sync.c, bookmarks.c, agent_conversations.c.) */
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
const browser_settings_t *s = settings_get();
*relay_buf = g_strdup(s->bootstrap_relays);
int count = 0;
char *saveptr = NULL;
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
while (line != NULL && count < max) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
urls_out[count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
return count;
}
/* Get the value of the first tag with the given name. Returns a pointer
* into the event's tags (or NULL). Not owned by caller. */
static const char *event_tag_value(const cJSON *event, const char *name) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return NULL;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1)) {
return t1->valuestring;
}
}
return NULL;
}
/* Check whether an event has a tag [name, value]. Returns 1/0. */
static int event_has_tag(const cJSON *event, const char *name,
const char *value) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) {
return 1;
}
}
return 0;
}
/* Collect all values of tags with the given name into a newly allocated
* cJSON array of strings. Caller must cJSON_Delete(). */
static cJSON *event_tag_values(const cJSON *event, const char *name) {
cJSON *result = cJSON_CreateArray();
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return result;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1)) {
cJSON_AddItemToArray(result, cJSON_CreateString(t1->valuestring));
}
}
return result;
}
/* ── Fetch / list ──────────────────────────────────────────────────── */
/* Parse a single kind 31123 event into an agent_skill_t struct.
* Returns 0 on success, -1 on error. On success, the skill's owned
* fields (content, requires_tools) are allocated and must be freed
* via agent_skills_free_list(). */
static int parse_skill_event(const cJSON *event, agent_skill_t *out) {
if (event == NULL || out == NULL) return -1;
memset(out, 0, sizeof(*out));
/* d-tag (unique identifier) */
const char *d = event_tag_value(event, "d");
if (d == NULL || d[0] == '\0') return -1;
snprintf(out->d_tag, sizeof(out->d_tag), "%s", d);
/* name tag */
const char *name = event_tag_value(event, "name");
if (name) {
snprintf(out->name, sizeof(out->name), "%s", name);
} else {
/* Fall back to the d-tag if no name tag. */
snprintf(out->name, sizeof(out->name), "%s", d);
}
/* description tag */
const char *desc = event_tag_value(event, "description");
if (desc) {
snprintf(out->description, sizeof(out->description), "%s", desc);
}
/* author pubkey */
cJSON *pk = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (cJSON_IsString(pk)) {
snprintf(out->pubkey, sizeof(out->pubkey), "%s", pk->valuestring);
}
/* content (system prompt template) */
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (cJSON_IsString(content)) {
out->content = g_strdup(content->valuestring);
} else {
out->content = g_strdup("");
}
/* requires_tool tags */
cJSON *tools = event_tag_values(event, "requires_tool");
if (tools != NULL) {
int n = cJSON_GetArraySize(tools);
if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS;
out->tool_count = n;
if (n > 0) {
out->requires_tools = g_new0(char *, n);
for (int i = 0; i < n; i++) {
cJSON *t = cJSON_GetArrayItem(tools, i);
if (cJSON_IsString(t)) {
out->requires_tools[i] = g_strdup(t->valuestring);
} else {
out->requires_tools[i] = g_strdup("");
}
}
}
cJSON_Delete(tools);
}
return 0;
}
void agent_skills_free_list(agent_skill_t *skills, int count) {
if (skills == NULL) return;
for (int i = 0; i < count; i++) {
g_free(skills[i].content);
if (skills[i].requires_tools) {
for (int j = 0; j < skills[i].tool_count; j++) {
g_free(skills[i].requires_tools[j]);
}
g_free(skills[i].requires_tools);
}
}
g_free(skills);
}
/* Fetch and parse all kind 31123 events into an array of agent_skill_t.
* Returns the array (caller must agent_skills_free_list()) and sets
* *count_out. Returns NULL on error or if no skills exist. */
static agent_skill_t *fetch_skill_structs(int *count_out) {
if (count_out == NULL) return NULL;
*count_out = 0;
cJSON *events = db_get_events_by_kind(AGENT_SKILL_KIND, 500);
if (events == NULL) return NULL;
int n = cJSON_GetArraySize(events);
if (n <= 0) {
cJSON_Delete(events);
return NULL;
}
/* Deduplicate by d-tag: keep the newest (events are newest-first
* from db_get_events_by_kind). */
agent_skill_t *skills = g_new0(agent_skill_t, n);
int count = 0;
cJSON *seen_d = cJSON_CreateArray();
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
const char *d = event_tag_value(ev, "d");
if (d == NULL || d[0] == '\0') continue;
/* Skip if we've already seen this d-tag. */
int seen = 0;
cJSON *s;
cJSON_ArrayForEach(s, seen_d) {
if (cJSON_IsString(s) && strcmp(s->valuestring, d) == 0) {
seen = 1;
break;
}
}
if (seen) continue;
cJSON_AddItemToArray(seen_d, cJSON_CreateString(d));
if (parse_skill_event(ev, &skills[count]) == 0) {
count++;
}
}
cJSON_Delete(seen_d);
cJSON_Delete(events);
if (count == 0) {
g_free(skills);
return NULL;
}
*count_out = count;
return skills;
}
cJSON *agent_skills_fetch(void) {
cJSON *result = cJSON_CreateArray();
int count = 0;
agent_skill_t *skills = fetch_skill_structs(&count);
if (skills == NULL) return result;
for (int i = 0; i < count; i++) {
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "d", skills[i].d_tag);
cJSON_AddStringToObject(obj, "name", skills[i].name);
cJSON_AddStringToObject(obj, "description", skills[i].description);
cJSON_AddStringToObject(obj, "pubkey", skills[i].pubkey);
cJSON_AddStringToObject(obj, "content", skills[i].content);
/* requires_tools array */
cJSON *tools = cJSON_CreateArray();
for (int j = 0; j < skills[i].tool_count; j++) {
cJSON_AddItemToArray(tools,
cJSON_CreateString(skills[i].requires_tools[j]));
}
cJSON_AddItemToObject(obj, "requires_tools", tools);
cJSON_AddItemToArray(result, obj);
}
agent_skills_free_list(skills, count);
return result;
}
/* ── Sovereign Browser Skill (default, from local settings) ─────────── */
cJSON *agent_skills_get_default(void) {
const browser_settings_t *s = settings_get();
cJSON *obj = cJSON_CreateObject();
/* d is empty — this skill is unsaved (not published to Nostr). */
cJSON_AddStringToObject(obj, "d", "");
cJSON_AddStringToObject(obj, "name",
s->agent_skill_name[0] ? s->agent_skill_name
: SETTINGS_AGENT_SKILL_NAME_DEFAULT);
cJSON_AddStringToObject(obj, "description",
s->agent_skill_description[0] ? s->agent_skill_description
: SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT);
cJSON_AddStringToObject(obj, "content",
s->agent_skill_template[0] ? s->agent_skill_template
: SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
cJSON_AddStringToObject(obj, "pubkey", "");
cJSON_AddBoolToObject(obj, "unsaved", 1);
/* Parse requires_tools (comma-separated) into a JSON array. */
cJSON *tools = cJSON_CreateArray();
const char *tools_str = s->agent_skill_requires_tools[0]
? s->agent_skill_requires_tools
: SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT;
char *buf = g_strdup(tools_str);
char *saveptr = NULL;
char *tok = strtok_r(buf, ",", &saveptr);
while (tok != NULL) {
/* Trim leading whitespace. */
while (*tok == ' ' || *tok == '\t') tok++;
if (tok[0] != '\0') {
cJSON_AddItemToArray(tools, cJSON_CreateString(tok));
}
tok = strtok_r(NULL, ",", &saveptr);
}
g_free(buf);
cJSON_AddItemToObject(obj, "requires_tools", tools);
return obj;
}
/* ── Selection (local settings) ────────────────────────────────────── */
cJSON *agent_skills_get_selected(void) {
cJSON *result = cJSON_CreateArray();
char *json_str = db_kv_get_copy(AGENT_SKILLS_SELECTED_KEY);
if (json_str == NULL || json_str[0] == '\0') {
g_free(json_str);
return result;
}
cJSON *parsed = cJSON_Parse(json_str);
g_free(json_str);
if (cJSON_IsArray(parsed)) {
cJSON *item;
cJSON_ArrayForEach(item, parsed) {
if (cJSON_IsString(item)) {
cJSON_AddItemToArray(result, cJSON_CreateString(item->valuestring));
}
}
}
if (parsed) cJSON_Delete(parsed);
return result;
}
int agent_skills_set_selected(const char *d_tags_json) {
if (d_tags_json == NULL) d_tags_json = "[]";
return db_kv_set(AGENT_SKILLS_SELECTED_KEY, d_tags_json);
}
cJSON *agent_skills_toggle_selected(const char *d_tag) {
if (d_tag == NULL || d_tag[0] == '\0') return NULL;
cJSON *selected = agent_skills_get_selected();
/* Check if already selected. */
int found = -1;
int n = cJSON_GetArraySize(selected);
for (int i = 0; i < n; i++) {
cJSON *item = cJSON_GetArrayItem(selected, i);
if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) {
found = i;
break;
}
}
if (found >= 0) {
/* Remove it. */
cJSON_DeleteItemFromArray(selected, found);
} else {
/* Add it (at the end to preserve selection order). */
cJSON_AddItemToArray(selected, cJSON_CreateString(d_tag));
}
/* Save back to db_kv. */
char *json = cJSON_PrintUnformatted(selected);
if (json) {
agent_skills_set_selected(json);
free(json);
}
return selected;
}
/* ── Build system prompt ───────────────────────────────────────────── */
char *agent_skills_build_system_prompt(void) {
/* Always start with the Sovereign Browser Skill template (the
* default system prompt from local settings). This is the base
* prompt; selected Nostr skills are appended after it. */
const browser_settings_t *s = settings_get();
const char *base = s->agent_skill_template[0]
? s->agent_skill_template
: SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT;
GString *combined = g_string_new(base);
/* Fetch selected skills and append their templates. */
cJSON *selected = agent_skills_get_selected();
if (selected != NULL) {
int n = cJSON_GetArraySize(selected);
if (n > 0) {
int skill_count = 0;
agent_skill_t *skills = fetch_skill_structs(&skill_count);
if (skills != NULL) {
for (int i = 0; i < n; i++) {
cJSON *item = cJSON_GetArrayItem(selected, i);
if (!cJSON_IsString(item)) continue;
const char *d = item->valuestring;
agent_skill_t *skill = NULL;
for (int j = 0; j < skill_count; j++) {
if (strcmp(skills[j].d_tag, d) == 0) {
skill = &skills[j];
break;
}
}
if (skill == NULL || skill->content == NULL ||
skill->content[0] == '\0') continue;
g_string_append(combined, "\n\n---\n\n");
g_string_append(combined, skill->content);
}
agent_skills_free_list(skills, skill_count);
}
}
cJSON_Delete(selected);
}
return g_string_free(combined, FALSE);
}
/* ── Publish ───────────────────────────────────────────────────────── */
/* Derive a d-tag from a skill name: lowercase, replace spaces/separators
* with hyphens, strip non-alphanumeric. Returns a newly allocated string
* (caller must g_free). */
static char *derive_d_tag(const char *name) {
if (name == NULL || name[0] == '\0') {
/* Fall back to a timestamp-based id. */
return g_strdup_printf("skill-%ld", (long)time(NULL));
}
/* Build a slug. */
GString *slug = g_string_new(NULL);
for (const char *p = name; *p; p++) {
char c = *p;
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
g_string_append_c(slug, c);
} else if (c >= 'A' && c <= 'Z') {
g_string_append_c(slug, c - 'A' + 'a');
} else if (c == ' ' || c == '_' || c == '-' || c == '.') {
g_string_append_c(slug, '-');
}
/* Skip other characters. */
}
/* Collapse consecutive hyphens and trim leading/trailing. */
GString *clean = g_string_new(NULL);
int prev_hyphen = 1; /* treat start as if previous was hyphen */
for (gsize i = 0; i < slug->len; i++) {
char c = slug->str[i];
if (c == '-') {
if (!prev_hyphen) {
g_string_append_c(clean, '-');
prev_hyphen = 1;
}
} else {
g_string_append_c(clean, c);
prev_hyphen = 0;
}
}
/* Trim trailing hyphen. */
if (clean->len > 0 && clean->str[clean->len - 1] == '-') {
g_string_truncate(clean, clean->len - 1);
}
g_string_free(slug, TRUE);
if (clean->len == 0) {
g_string_free(clean, TRUE);
return g_strdup_printf("skill-%ld", (long)time(NULL));
}
/* Append a short timestamp suffix to ensure uniqueness. */
char *result = g_strdup_printf("%s-%ld", clean->str, (long)time(NULL) % 100000);
g_string_free(clean, TRUE);
return result;
}
char *agent_skills_publish(const char *name,
const char *description,
const char *content,
const char *requires_tools_json) {
if (!g_have_signer) {
g_printerr("[agent_skills] No signer, cannot publish\n");
return NULL;
}
if (content == NULL || content[0] == '\0') {
g_printerr("[agent_skills] Content is required\n");
return NULL;
}
/* Derive the d-tag from the name. */
char *d_tag = derive_d_tag(name);
/* Build tags:
* [["d", d_tag],
* ["name", name],
* ["description", desc],
* ["client", "sovereign_browser"],
* ["requires_tool", tool1], ...] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag_arr = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag));
cJSON_AddItemToArray(tags, d_tag_arr);
if (name && name[0]) {
cJSON *name_tag = cJSON_CreateArray();
cJSON_AddItemToArray(name_tag, cJSON_CreateString("name"));
cJSON_AddItemToArray(name_tag, cJSON_CreateString(name));
cJSON_AddItemToArray(tags, name_tag);
}
if (description && description[0]) {
cJSON *desc_tag = cJSON_CreateArray();
cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description"));
cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description));
cJSON_AddItemToArray(tags, desc_tag);
}
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Parse requires_tools_json and add a requires_tool tag for each. */
if (requires_tools_json && requires_tools_json[0]) {
cJSON *tools = cJSON_Parse(requires_tools_json);
if (cJSON_IsArray(tools)) {
int n = cJSON_GetArraySize(tools);
if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS;
for (int i = 0; i < n; i++) {
cJSON *t = cJSON_GetArrayItem(tools, i);
if (cJSON_IsString(t) && t->valuestring[0]) {
cJSON *rt_tag = cJSON_CreateArray();
cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool"));
cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring));
cJSON_AddItemToArray(tags, rt_tag);
}
}
}
if (tools) cJSON_Delete(tools);
}
/* Create and sign the event. Skills are PUBLIC — no encryption.
* The content is the raw system prompt template. */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_SKILL_KIND, content, tags, g_signer, time(NULL));
cJSON_Delete(tags);
if (event == NULL) {
g_printerr("[agent_skills] Failed to create/sign event\n");
g_free(d_tag);
return NULL;
}
/* Store in SQLite. */
db_store_event(event);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_skills] Published skill '%s' (d:%s) to %d/%d relays\n",
name ? name : "(unnamed)", d_tag, success_count, relay_count);
} else {
g_print("[agent_skills] No relays configured, skill stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return d_tag;
}
/* ── Delete ────────────────────────────────────────────────────────── */
int agent_skills_delete(const char *d_tag) {
if (d_tag == NULL || d_tag[0] == '\0') return -1;
if (g_pubkey[0] == '\0') return -1;
/* Find the kind 31123 event with this d-tag authored by the user. */
cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500);
if (events == NULL) return -1;
char *event_id = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag)) {
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
if (cJSON_IsString(id_item)) {
event_id = g_strdup(id_item->valuestring);
}
break;
}
}
cJSON_Delete(events);
if (event_id == NULL) {
g_printerr("[agent_skills] Skill '%s' not found for delete\n", d_tag);
return -1;
}
/* Publish a kind 5 deletion event referencing the skill event. */
if (g_have_signer) {
cJSON *tags = cJSON_CreateArray();
cJSON *e_tag = cJSON_CreateArray();
cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id));
cJSON_AddItemToArray(tags, e_tag);
cJSON *event = nostr_create_and_sign_event_with_signer(
5, "Deleted skill", tags, g_signer, time(NULL));
cJSON_Delete(tags);
if (event) {
db_store_event(event);
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results =
synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_skills] Published deletion for '%s' to %d/%d relays\n",
d_tag, success_count, relay_count);
}
g_free(relay_buf);
cJSON_Delete(event);
}
}
/* Remove from local SQLite cache. */
db_delete_event(event_id);
g_free(event_id);
/* Also remove from the selected list if present. */
cJSON *selected = agent_skills_get_selected();
if (selected) {
int sn = cJSON_GetArraySize(selected);
for (int i = 0; i < sn; i++) {
cJSON *item = cJSON_GetArrayItem(selected, i);
if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) {
cJSON_DeleteItemFromArray(selected, i);
break;
}
}
char *json = cJSON_PrintUnformatted(selected);
if (json) {
agent_skills_set_selected(json);
free(json);
}
cJSON_Delete(selected);
}
return 0;
}
/* ── Update (re-publish an existing skill) ──────────────────────────── */
int agent_skills_update(const char *d_tag,
const char *name,
const char *description,
const char *content,
const char *requires_tools_json) {
if (d_tag == NULL || d_tag[0] == '\0') return -1;
if (content == NULL || content[0] == '\0') return -1;
if (!g_have_signer || g_pubkey[0] == '\0') {
g_printerr("[agent_skills] No signer, cannot update\n");
return -1;
}
/* Find the existing kind 31123 event with this d-tag authored by
* the user. We reuse the existing d-tag (addressable event) so the
* update replaces the prior version. */
cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500);
if (events == NULL) {
g_printerr("[agent_skills] No skill events found for update\n");
return -1;
}
cJSON *found = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag)) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
cJSON_Delete(events);
if (found == NULL) {
g_printerr("[agent_skills] Skill '%s' not found for update\n", d_tag);
return -1;
}
/* If name/description not provided, keep the existing values. */
char name_buf[128];
char desc_buf[512];
if (name == NULL || name[0] == '\0') {
const char *old = event_tag_value(found, "name");
snprintf(name_buf, sizeof(name_buf), "%s", old ? old : d_tag);
name = name_buf;
}
if (description == NULL) {
const char *old = event_tag_value(found, "description");
snprintf(desc_buf, sizeof(desc_buf), "%s", old ? old : "");
description = desc_buf;
}
/* Build the new tags: same d-tag, new name/description, client tag,
* and requires_tool tags. */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag_arr = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag));
cJSON_AddItemToArray(tags, d_tag_arr);
if (name && name[0]) {
cJSON *name_tag = cJSON_CreateArray();
cJSON_AddItemToArray(name_tag, cJSON_CreateString("name"));
cJSON_AddItemToArray(name_tag, cJSON_CreateString(name));
cJSON_AddItemToArray(tags, name_tag);
}
if (description && description[0]) {
cJSON *desc_tag = cJSON_CreateArray();
cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description"));
cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description));
cJSON_AddItemToArray(tags, desc_tag);
}
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
if (requires_tools_json && requires_tools_json[0]) {
cJSON *tools = cJSON_Parse(requires_tools_json);
if (cJSON_IsArray(tools)) {
int tn = cJSON_GetArraySize(tools);
if (tn > AGENT_SKILL_MAX_TOOLS) tn = AGENT_SKILL_MAX_TOOLS;
for (int i = 0; i < tn; i++) {
cJSON *t = cJSON_GetArrayItem(tools, i);
if (cJSON_IsString(t) && t->valuestring[0]) {
cJSON *rt_tag = cJSON_CreateArray();
cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool"));
cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring));
cJSON_AddItemToArray(tags, rt_tag);
}
}
}
if (tools) cJSON_Delete(tools);
}
/* Create and sign the new event (same d-tag → replaces the old). */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_SKILL_KIND, content, tags, g_signer, time(NULL));
cJSON_Delete(tags);
cJSON_Delete(found);
if (event == NULL) {
g_printerr("[agent_skills] Failed to create/sign update event\n");
return -1;
}
/* Store in SQLite (replaces the old event by id; the d-tag is the
* same so fetch_skill_structs dedup keeps the newest). */
db_store_event(event);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_skills] Updated skill '%s' to %d/%d relays\n",
d_tag, success_count, relay_count);
} else {
g_print("[agent_skills] No relays configured, update stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return 0;
}

204
src/agent_skills.h Normal file
View File

@@ -0,0 +1,204 @@
/*
* agent_skills.h — Nostr kind 31123 skill management for sovereign_browser
*
* Skills are PUBLIC Nostr events (kind 31123) that define system prompt
* templates, LLM parameters, and tool requirements. They are addressable
* events (NIP-51 list kind variant) with a "d" tag (unique identifier),
* a "name" tag, and content that is the system prompt template.
*
* sovereign_browser has a unique advantage over ~/lt/client/www/ai.html:
* it has browser tools + filesystem/shell tools, so skills with
* "requires_tool" tags are fully functional here.
*
* Users select multiple skills via checkboxes; their templates are
* concatenated into the system prompt in selection order. The agent loop
* calls agent_skills_build_system_prompt() to get the combined prompt.
*
* See plans/cross-project-agent-sync.md (Phase C/D) and
* ~/lt/client/plans/ai-skills-integration.md for the full design.
*/
#ifndef AGENT_SKILLS_H
#define AGENT_SKILLS_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
/* nostr_signer_t is needed for init */
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
/* The Nostr kind for skills (NIP-51 list kind variant, addressable). */
#define AGENT_SKILL_KIND 31123
/* db_kv key for the selected skill d-tags (stored as a JSON array string). */
#define AGENT_SKILLS_SELECTED_KEY "agent.selected_skills"
/* Maximum number of requires_tool tags per skill. */
#define AGENT_SKILL_MAX_TOOLS 32
/* ── Skill struct ──────────────────────────────────────────────────── */
typedef struct {
char d_tag[128]; /* unique identifier */
char name[128]; /* skill name */
char description[512]; /* short description */
char pubkey[65]; /* author pubkey (hex) */
char *content; /* system prompt template (owned, g_free) */
char **requires_tools; /* array of tool name strings (owned, g_free) */
int tool_count;
} agent_skill_t;
/* ── Lifecycle ─────────────────────────────────────────────────────── */
/*
* Initialize the skills module. Stores the signer + pubkey references
* for publish/delete operations.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
*
* Call after login.
*/
void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex);
/*
* Update the signer reference (e.g. after switching identity).
*/
void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex);
/* ── Fetch / list ──────────────────────────────────────────────────── */
/*
* Fetch kind 31123 skill events from the local SQLite cache and parse
* them into skill structs. Skills are public events from any author.
*
* Returns a newly allocated cJSON array of skill summary objects:
* [{"d":"...", "name":"...", "description":"...",
* "requires_tools":["tool1","tool2"],
* "content":"...", "pubkey":"..."}, ...]
* Returns an empty array on error or if no skills exist.
* Caller must cJSON_Delete() the result.
*/
cJSON *agent_skills_fetch(void);
/*
* Get the Sovereign Browser Skill — the default skill stored in local
* settings (not from Nostr). Returns a newly allocated cJSON object:
* {"d":"", "name":"...", "description":"...", "content":"...",
* "requires_tools":["tool1","tool2"],
* "unsaved":true, "pubkey":""}
* Caller must cJSON_Delete() the result.
*/
cJSON *agent_skills_get_default(void);
/*
* Free an array of agent_skill_t structs (and each skill's owned fields).
* skills — the array (may be NULL)
* count — number of entries in the array
*/
void agent_skills_free_list(agent_skill_t *skills, int count);
/* ── Selection (local settings) ────────────────────────────────────── */
/*
* Get the list of currently selected skill d-tags from local settings.
*
* Returns a newly allocated cJSON array of d-tag strings:
* ["d-tag-1", "d-tag-2", ...]
* Returns an empty array if none selected. Caller must cJSON_Delete().
*/
cJSON *agent_skills_get_selected(void);
/*
* Save the selected skill d-tags to local settings (db_kv).
*
* d_tags_json — a JSON array string of d-tags, e.g. "[\"a\",\"b\"]"
*
* Returns 0 on success, -1 on error.
*/
int agent_skills_set_selected(const char *d_tags_json);
/*
* Toggle selection of a skill: add the d-tag if not selected, remove it
* if already selected. Saves the updated selection to local settings.
*
* d_tag — the skill's d-tag value
*
* Returns a newly allocated cJSON array of the updated selection
* (caller must cJSON_Delete()), or NULL on error.
*/
cJSON *agent_skills_toggle_selected(const char *d_tag);
/* ── Build system prompt ───────────────────────────────────────────── */
/*
* Build the system prompt by concatenating the selected skills'
* templates in selection order. Templates are separated by
* "\n\n---\n\n".
*
* Returns a newly allocated string (caller must g_free), or NULL if no
* skills are selected (caller should fall back to the default prompt).
*/
char *agent_skills_build_system_prompt(void);
/* ── Publish / delete ──────────────────────────────────────────────── */
/*
* Create and publish a new kind 31123 skill event. Signs with the
* user's key, publishes to bootstrap relays, and stores in SQLite.
* Skills are PUBLIC events — no NIP-44 encryption.
*
* name — skill name (also used to derive the d-tag)
* description — short description (may be NULL or "")
* content — the system prompt template (required)
* requires_tools_json — JSON array string of tool names, e.g.
* "[\"browser\",\"fs\"]" (may be NULL or "[]")
*
* Returns a newly allocated string with the d-tag of the new skill
* (caller must g_free), or NULL on error.
*/
char *agent_skills_publish(const char *name,
const char *description,
const char *content,
const char *requires_tools_json);
/*
* Publish a kind 5 deletion event for a skill authored by the user,
* and remove it from the local SQLite cache.
*
* d_tag — the skill's d-tag value
*
* Returns 0 on success, -1 on error.
*/
int agent_skills_delete(const char *d_tag);
/*
* Update an existing kind 31123 skill event with new content. Fetches
* the existing event by d-tag (authored by the user), updates its
* name/description/content/requires_tools, re-signs, and re-publishes.
*
* d_tag — the skill's d-tag value (identifies the skill)
* name — new skill name (may be NULL to keep existing)
* description — new description (may be NULL to keep existing)
* content — new system prompt template (required)
* requires_tools_json — JSON array string of tool names (may be NULL)
*
* Returns 0 on success, -1 on error.
*/
int agent_skills_update(const char *d_tag,
const char *name,
const char *description,
const char *content,
const char *requires_tools_json);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SKILLS_H */

46
src/agent_tool_catalog.h Normal file
View File

@@ -0,0 +1,46 @@
/*
* agent_tool_catalog.h — shared tool catalog definitions
*
* Exposes the tool_defs[] array, its count, and build_tools_list()
* so that both the MCP server (agent_mcp.c) and the embedded agent
* (agent_llm.c, future) can access the same tool catalog.
*
* The definitions live in agent_mcp.c; this header just makes them
* non-static and declares them here.
*/
#ifndef AGENT_TOOL_CATALOG_H
#define AGENT_TOOL_CATALOG_H
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── Tool definition struct ────────────────────────────────────────── *
* Each entry has a name, human-readable description, and a pre-built
* JSON Schema string for the tool's input parameters.
*/
typedef struct {
const char *name;
const char *description;
const char *schema_json; /* pre-built JSON schema string */
} mcp_tool_def_t;
/* The shared tool catalog array and its length. */
extern const mcp_tool_def_t tool_defs[];
extern const int tool_defs_count;
/*
* Build a cJSON array of tool definitions in the MCP tools/list format:
* [{"name":"...","description":"...","inputSchema":{...}}, ...]
* The caller frees the returned cJSON*.
*/
cJSON *build_tools_list(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_TOOL_CATALOG_H */

View File

@@ -11,6 +11,7 @@
#include "agent_login.h"
#include "agent_snapshot.h"
#include "agent_server.h"
#include "agent_fs_tools.h"
#include "tab_manager.h"
#include "settings.h"
#include "search.h"
@@ -72,11 +73,14 @@ static char *normalize_url(const char *input) {
return search_build_search_url(input);
}
/* ── Get active webview (or NULL if not logged in / no tabs) ──────── */
/* ── Get active webview (or NULL if not logged in / no tabs) ────────
* When the agent sidebar is open, the "active" webview (the one with
* focus) might be the sidebar chat page. Agent tools must always
* operate on the MAIN webview (the web page), so we use
* tab_manager_get_main_webview() which never returns the sidebar. */
static WebKitWebView *get_active_webview(void) {
tab_info_t *tab = tab_manager_get_active();
return tab ? tab->webview : NULL;
return tab_manager_get_main_webview();
}
/* ── Coordinate-based click via GDK event synthesis ────────────────── */
@@ -407,6 +411,29 @@ static cJSON *tool_switch_identity(cJSON *params) {
/* ── Navigation tools ─────────────────────────────────────────────── */
/* Idle callback data for main-thread webview navigation. WebKitGTK is
* NOT thread-safe — webkit_web_view_load_uri() must be called on the
* GTK main thread. The agent loop runs on a background thread, so we
* hop to the main thread via g_idle_add() to perform the navigation.
* (Issue 3: calling webkit_web_view_load_uri() directly from the
* agent-loop background thread caused an "Aborted" segfault.) */
typedef struct {
WebKitWebView *webview;
char *url;
} load_uri_idle_t;
static gboolean load_uri_idle(gpointer user_data) {
load_uri_idle_t *ctx = (load_uri_idle_t *)user_data;
if (ctx && ctx->webview && ctx->url) {
webkit_web_view_load_uri(ctx->webview, ctx->url);
}
if (ctx) {
g_free(ctx->url);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
static cJSON *tool_open(cJSON *params) {
const char *url = get_string_param(params, "url");
if (!url || !url[0]) return make_error("MISSING_PARAM", "Provide 'url'");
@@ -414,24 +441,75 @@ static cJSON *tool_open(cJSON *params) {
char *normalized = normalize_url(url);
if (!normalized) return make_error("INVALID_URL", "Invalid URL");
tab_info_t *tab = tab_manager_get_active();
if (!tab) {
/* Always operate on the MAIN webview (the web page), never the
* sidebar chat webview. tab_manager_get_main_webview() returns
* tab->webview directly, never tab->sidebar_webview. */
WebKitWebView *wv = tab_manager_get_main_webview();
if (!wv) {
g_free(normalized);
return make_error("NO_TAB", "No active tab");
}
webkit_web_view_load_uri(tab->webview, normalized);
/* Dispatch the load to the GTK main thread. The agent loop (and
* the libsoup MCP thread) are not the main thread, so calling
* webkit_web_view_load_uri() directly would crash. g_idle_add()
* schedules the call on the default main context, which is run by
* the GTK main loop on the main thread. */
load_uri_idle_t *ctx = g_new(load_uri_idle_t, 1);
ctx->webview = wv;
ctx->url = g_strdup(normalized);
g_idle_add(load_uri_idle, ctx);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "url", normalized);
g_free(normalized);
return make_success(data);
}
/* Idle callback data for simple main-thread webview actions (back,
* forward, reload). Like tool_open, these must run on the GTK main
* thread because WebKitGTK is not thread-safe and the agent loop
* runs on a background thread. */
typedef enum {
WEBVIEW_ACTION_BACK,
WEBVIEW_ACTION_FORWARD,
WEBVIEW_ACTION_RELOAD
} webview_action_t;
typedef struct {
WebKitWebView *webview;
webview_action_t action;
} webview_action_idle_t;
static gboolean webview_action_idle(gpointer user_data) {
webview_action_idle_t *ctx = (webview_action_idle_t *)user_data;
if (ctx && ctx->webview) {
switch (ctx->action) {
case WEBVIEW_ACTION_BACK:
if (webkit_web_view_can_go_back(ctx->webview))
webkit_web_view_go_back(ctx->webview);
break;
case WEBVIEW_ACTION_FORWARD:
if (webkit_web_view_can_go_forward(ctx->webview))
webkit_web_view_go_forward(ctx->webview);
break;
case WEBVIEW_ACTION_RELOAD:
webkit_web_view_reload_bypass_cache(ctx->webview);
break;
}
}
g_free(ctx);
return G_SOURCE_REMOVE;
}
static cJSON *tool_back(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_go_back(wv);
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_BACK;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
@@ -439,7 +517,10 @@ static cJSON *tool_forward(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_go_forward(wv);
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_FORWARD;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
@@ -447,7 +528,10 @@ static cJSON *tool_reload(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_reload_bypass_cache(wv);
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_RELOAD;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
@@ -4068,6 +4152,14 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return make_error("MISSING_TOOL", "No 'tool' field in request");
}
/* ── Filesystem & shell tools (work before login) ──────────── *
* These are system-level tools that give the agent direct access
* to the qube's filesystem and shell. They don't require a Nostr
* login, so we dispatch them before the login check below. */
if (agent_fs_is_tool(tool_name)) {
return agent_fs_tools_dispatch(tool_name, params);
}
/* Check login requirement for known tools. */
gboolean requires_login = TRUE;
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||

394
src/db.c
View File

@@ -16,6 +16,7 @@
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
/* ── Global state ──────────────────────────────────────────────────── */
@@ -117,7 +118,25 @@ static const char *SCHEMA_SQL =
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
" ON event_tags(tag_name, tag_value);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
" ON event_tags(event_id);";
" ON event_tags(event_id);"
"CREATE TABLE IF NOT EXISTS agent_sessions ("
" id TEXT PRIMARY KEY,"
" title TEXT,"
" created_at INTEGER,"
" updated_at INTEGER"
");"
"CREATE TABLE IF NOT EXISTS agent_messages ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" session_id TEXT,"
" role TEXT,"
" content TEXT,"
" tool_calls TEXT,"
" tool_call_id TEXT,"
" created_at INTEGER,"
" FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE"
");"
"CREATE INDEX IF NOT EXISTS idx_agent_messages_session "
" ON agent_messages(session_id, created_at);";
/* ── Init / Close ──────────────────────────────────────────────────── */
@@ -432,6 +451,90 @@ int db_count_events(const char *pubkey_hex, int kind) {
return count;
}
cJSON *db_get_events_by_kind(int kind, int limit) {
if (g_db == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int lim = (limit > 0) ? limit : 10000;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE kind = ? "
"ORDER BY created_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare get_events_by_kind failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_int(stmt, 1, kind);
sqlite3_bind_int(stmt, 2, lim);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
cJSON *event = cJSON_Parse(json_str);
if (event) {
cJSON_AddItemToArray(array, event);
}
}
}
sqlite3_finalize(stmt);
return array;
}
int db_delete_event(const char *event_id) {
if (g_db == NULL || event_id == NULL) return -1;
char *err = NULL;
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
/* Delete tags first. */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM event_tags WHERE event_id = ?;",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
/* Delete the event row. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM events WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] delete_event prepare failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] delete_event step failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
return 0;
}
/* ── Key-Value store ───────────────────────────────────────────────── */
int db_kv_set(const char *key, const char *value) {
@@ -762,3 +865,292 @@ int db_session_clear(void) {
}
return 0;
}
/* ── Agent chat sessions ───────────────────────────────────────────── */
char *db_agent_session_create(const char *title) {
if (g_db == NULL) return NULL;
char *id = g_uuid_string_random();
if (id == NULL) {
g_printerr("[db] agent_session_create: g_uuid_string_random failed\n");
return NULL;
}
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO agent_sessions (id, title, created_at, updated_at) "
"VALUES (?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_create: prepare failed: %s\n",
sqlite3_errmsg(g_db));
g_free(id);
return NULL;
}
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 3, now);
sqlite3_bind_int64(stmt, 4, now);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_create: insert failed: %s\n",
sqlite3_errmsg(g_db));
g_free(id);
return NULL;
}
return id;
}
char *db_agent_session_get_latest(void) {
if (g_db == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id FROM agent_sessions ORDER BY updated_at DESC LIMIT 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_get_latest: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
char *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *id = (const char *)sqlite3_column_text(stmt, 0);
if (id) {
result = g_strdup(id);
}
}
sqlite3_finalize(stmt);
return result;
}
int db_agent_session_update(const char *session_id, const char *title) {
if (g_db == NULL || session_id == NULL) return -1;
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"UPDATE agent_sessions SET title = ?, updated_at = ? "
"WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_update: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, title ? title : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 2, now);
sqlite3_bind_text(stmt, 3, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_update: update failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return 0;
}
int db_agent_session_list(char ***ids_out, char ***titles_out,
int *count_out) {
if (g_db == NULL || ids_out == NULL || titles_out == NULL ||
count_out == NULL) {
return -1;
}
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id, title FROM agent_sessions "
"ORDER BY updated_at DESC;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_list: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
/* First pass: count rows. */
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
/* Allocate arrays. */
*ids_out = g_new0(char *, count);
*titles_out = g_new0(char *, count);
*count_out = 0;
/* Second pass: fill. */
int i = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
const char *id = (const char *)sqlite3_column_text(stmt, 0);
const char *title = (const char *)sqlite3_column_text(stmt, 1);
(*ids_out)[i] = g_strdup(id ? id : "");
(*titles_out)[i] = g_strdup(title ? title : "");
i++;
}
sqlite3_finalize(stmt);
*count_out = i;
return i;
}
int db_agent_message_add(const char *session_id, const char *role,
const char *content, const char *tool_calls,
const char *tool_call_id) {
if (g_db == NULL || session_id == NULL || role == NULL) return -1;
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO agent_messages "
"(session_id, role, content, tool_calls, tool_call_id, created_at) "
"VALUES (?, ?, ?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_message_add: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, role, -1, SQLITE_TRANSIENT);
if (content) {
sqlite3_bind_text(stmt, 3, content, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 3);
}
if (tool_calls) {
sqlite3_bind_text(stmt, 4, tool_calls, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 4);
}
if (tool_call_id) {
sqlite3_bind_text(stmt, 5, tool_call_id, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 5);
}
sqlite3_bind_int64(stmt, 6, now);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_message_add: insert failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return (int)sqlite3_last_insert_rowid(g_db);
}
cJSON *db_agent_message_list(const char *session_id) {
if (g_db == NULL || session_id == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id, role, content, tool_calls, tool_call_id "
"FROM agent_messages WHERE session_id = ? "
"ORDER BY created_at ASC, id ASC;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_message_list: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON *msg = cJSON_CreateObject();
cJSON_AddNumberToObject(msg, "id",
(double)sqlite3_column_int64(stmt, 0));
const char *role = (const char *)sqlite3_column_text(stmt, 1);
cJSON_AddStringToObject(msg, "role", role ? role : "");
/* content may be NULL. */
if (sqlite3_column_type(stmt, 2) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "content");
} else {
const char *content = (const char *)sqlite3_column_text(stmt, 2);
cJSON_AddStringToObject(msg, "content", content ? content : "");
}
/* tool_calls may be NULL. */
if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "tool_calls");
} else {
const char *tc = (const char *)sqlite3_column_text(stmt, 3);
cJSON_AddStringToObject(msg, "tool_calls", tc ? tc : "");
}
/* tool_call_id may be NULL. */
if (sqlite3_column_type(stmt, 4) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "tool_call_id");
} else {
const char *tcid = (const char *)sqlite3_column_text(stmt, 4);
cJSON_AddStringToObject(msg, "tool_call_id", tcid ? tcid : "");
}
cJSON_AddItemToArray(array, msg);
}
sqlite3_finalize(stmt);
return array;
}
int db_agent_session_delete(const char *session_id) {
if (g_db == NULL || session_id == NULL) return -1;
/* Delete messages first (in case foreign keys are not enforced). */
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"DELETE FROM agent_messages WHERE session_id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_delete: prepare messages failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_delete: delete messages failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
/* Delete the session row. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM agent_sessions WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_delete: prepare session failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_delete: delete session failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return 0;
}

View File

@@ -95,6 +95,24 @@ cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
*/
int db_count_events(const char *pubkey_hex, int kind);
/*
* Fetch all events of a given kind from ALL authors (no pubkey filter),
* newest first. Used for public events like kind 31123 skills that are
* authored by anyone.
*
* limit — max number of events (0 = no limit)
*
* Returns a cJSON array of event objects. Caller must cJSON_Delete().
* Returns NULL on error.
*/
cJSON *db_get_events_by_kind(int kind, int limit);
/*
* Delete a single event by its event id (and its tags, via cascade).
* Returns 0 on success, -1 on error.
*/
int db_delete_event(const char *event_id);
/* ── Key-Value store ───────────────────────────────────────────────── */
/*
@@ -196,6 +214,60 @@ int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
*/
int db_session_clear(void);
/* ── Agent chat sessions ───────────────────────────────────────────── */
/*
* Create a new chat session. Returns the session ID (newly allocated string,
* caller must g_free). Returns NULL on error.
*/
char *db_agent_session_create(const char *title);
/*
* Get the most recent chat session ID. Returns a newly allocated string,
* or NULL if no sessions exist. Caller must g_free.
*/
char *db_agent_session_get_latest(void);
/*
* Update a session's title and updated_at timestamp.
* Returns 0 on success, -1 on error.
*/
int db_agent_session_update(const char *session_id, const char *title);
/*
* List all chat sessions, newest first. Fills arrays with session IDs and
* titles. Caller must free each string and the arrays.
* Returns the number of sessions, or -1 on error.
*/
int db_agent_session_list(char ***ids_out, char ***titles_out,
int *count_out);
/*
* Add a message to a chat session.
* role — "user", "assistant", "tool", or "system"
* content — message text (may be NULL for assistant msgs with only tool_calls)
* tool_calls — JSON string of tool calls array (may be NULL)
* tool_call_id — for role="tool", the ID of the tool call this responds to (may be NULL)
* Returns the message row ID (>0) on success, -1 on error.
*/
int db_agent_message_add(const char *session_id, const char *role,
const char *content, const char *tool_calls,
const char *tool_call_id);
/*
* Load all messages for a session, ordered by created_at (oldest first).
* Returns a cJSON array of message objects, each with:
* {"id":N, "role":"...", "content":"...", "tool_calls":"...", "tool_call_id":"..."}
* Returns NULL on error. Caller must cJSON_Delete().
*/
cJSON *db_agent_message_list(const char *session_id);
/*
* Delete a chat session and all its messages.
* Returns 0 on success, -1 on error.
*/
int db_agent_session_delete(const char *session_id);
#ifdef __cplusplus
}
#endif

View File

@@ -50,6 +50,8 @@
#include "profile.h"
#include "relay_fetch.h"
#include "bookmarks.h"
#include "agent_conversations.h"
#include "agent_skills.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
@@ -89,6 +91,8 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
/* Update modules that hold a signer reference. */
settings_sync_set_signer(signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
agent_conversations_set_signer(signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* If this is the first login (we're still on global.db), switch to
* the per-user profile database. If we're already on a per-user db
@@ -110,6 +114,7 @@ void app_clear_signer(void) {
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
agent_conversations_set_signer(NULL, NULL);
}
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
@@ -253,6 +258,22 @@ void on_menu_profile(GtkMenuItem *item, gpointer data) {
}
}
/* The hamburger-menu "Agent Setup…" item navigates the active tab to
* the sovereign://agents internal page, which renders the agent provider
* configuration UI and a link to open the agent chat. */
void on_menu_agent(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://agents");
} else {
/* No active tab — open a new one pointed at the agent page. */
tab_manager_new_tab("sovereign://agents");
}
}
/* ---- Keyboard shortcuts --------------------------------------------- *
* All browser-level shortcuts are configurable via the shortcuts module.
* The on_key_press handler looks up the action for the pressed key
@@ -361,6 +382,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
tab_manager_toggle_inspector();
return TRUE;
case SHORTCUT_TOGGLE_SIDEBAR:
tab_manager_toggle_sidebar();
return TRUE;
default:
return FALSE;
}
@@ -742,6 +767,18 @@ int main(int argc, char **argv) {
settings_sync_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the conversation persistence module (kind 30078
* conversations). In no-login/read-only mode, the signer is NULL
* so conversations are not encrypted/synced. */
agent_conversations_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the skills module (kind 31123 public skill events).
* In no-login/read-only mode, the signer is NULL so skills cannot
* be published/deleted (but can still be fetched and selected). */
agent_skills_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Set the user's avatar on the tab bar from their kind 0 profile.
* If the profile hasn't been fetched yet (relay fetch is still
* running), this shows the default icon. The avatar will be updated

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
return -1;
}
g_print("[relay] Fetching kind 0/3/10002/30003/30078 for %s from %d relay(s)...\n",
g_print("[relay] Fetching kind 0/3/10002/30003/30078/31123 for %s from %d relay(s)...\n",
pubkey_hex, relay_count);
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002, 30003, 30078]} */
@@ -87,7 +87,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
/* Store each event in the database. Kind 30003 (bookmark sets) are
* also decrypted and loaded into the in-memory bookmarks list.
* Kind 30078 (NIP-78 app data) is stored and merged into local
* settings if it's our sovereign_browser settings event. */
* settings if it's the shared d:user-settings event (or the legacy
* d:sovereign_browser event, which is migrated on merge). */
int stored = 0;
for (int i = 0; i < result_count; i++) {
if (results[i] == NULL) continue;
@@ -104,7 +105,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
} else if (kind == 30078) {
/* Store in SQLite first, then try to merge into local settings.
* settings_sync_merge_from_nostr checks the d-tag and only
* merges if it's our "sovereign_browser" event. */
* merges if it's the shared "user-settings" event (or the
* legacy "sovereign_browser" event, which is migrated). */
if (db_store_event(results[i]) == 0) {
stored++;
}
@@ -132,6 +134,58 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
return stored;
}
/* Fetch kind 31123 skill events from relays. Skills are PUBLIC events
* authored by ANY user (not just the logged-in user), so this uses a
* separate query without an authors filter. The events are stored in
* the SQLite cache for agent_skills_fetch() to read.
*
* Returns the number of events stored, or 0 on error. */
static int relay_fetch_skills(const char **relay_urls, int relay_count) {
if (relay_urls == NULL || relay_count <= 0) return 0;
g_print("[relay] Fetching kind 31123 skills from %d relay(s)...\n",
relay_count);
/* Build the filter: {"kinds": [31123], "limit": 500} — no authors
* filter since skills are public and from any author. */
cJSON *filter = cJSON_CreateObject();
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(31123));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddNumberToObject(filter, "limit", 500);
int result_count = 0;
cJSON **results = synchronous_query_relays_with_progress(
relay_urls, relay_count, filter,
RELAY_QUERY_ALL_RESULTS, &result_count,
RELAY_TIMEOUT_SECONDS,
NULL, NULL,
0, NULL
);
cJSON_Delete(filter);
if (results == NULL || result_count <= 0) {
g_print("[relay] No skill events found\n");
if (results) free(results);
return 0;
}
int stored = 0;
for (int i = 0; i < result_count; i++) {
if (results[i] == NULL) continue;
if (db_store_event(results[i]) == 0) {
stored++;
}
cJSON_Delete(results[i]);
}
free(results);
g_print("[relay] Fetched %d skill event(s), stored %d\n",
result_count, stored);
return stored;
}
/* Idle callback wrapper to refresh the avatar on the main thread. */
static gboolean avatar_refresh_idle(gpointer data) {
char *pubkey = (char *)data;
@@ -178,6 +232,9 @@ gpointer relay_fetch_thread(gpointer data) {
g_print("[relay] Background fetch started (%d relays)\n", relay_count);
relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count);
/* Fetch public kind 31123 skill events (from any author). */
relay_fetch_skills(relay_urls, relay_count);
g_free(relay_buf);
g_free(pubkey_hex);
return NULL;

View File

@@ -71,11 +71,43 @@ static void settings_set_defaults(browser_settings_t *s) {
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
snprintf(s->search_engine, sizeof(s->search_engine), "%s",
SETTINGS_SEARCH_ENGINE_DEFAULT);
s->theme_dark = TRUE; /* default: dark mode */
s->theme_dark = FALSE; /* default: light mode */
s->inspector_x = -1; /* -1 = let window manager decide */
s->inspector_y = -1;
s->inspector_w = -1;
s->inspector_h = -1;
/* Agent LLM provider settings */
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url), "%s",
SETTINGS_AGENT_LLM_BASE_URL_DEFAULT);
s->agent_llm_api_key[0] = '\0'; /* empty by default */
snprintf(s->agent_llm_model, sizeof(s->agent_llm_model), "%s",
SETTINGS_AGENT_LLM_MODEL_DEFAULT);
s->agent_llm_system_prompt[0] = '\0'; /* legacy alias for skill_template */
s->agent_max_iterations = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT;
/* Sovereign Browser Skill defaults. The template defaults to the
* built-in system prompt; the other fields describe the skill. */
snprintf(s->agent_skill_name,
sizeof(s->agent_skill_name), "%s",
SETTINGS_AGENT_SKILL_NAME_DEFAULT);
snprintf(s->agent_skill_description,
sizeof(s->agent_skill_description), "%s",
SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT);
snprintf(s->agent_skill_template,
sizeof(s->agent_skill_template), "%s",
SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
snprintf(s->agent_skill_requires_tools,
sizeof(s->agent_skill_requires_tools), "%s",
SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT);
/* Multi-provider catalog — empty by default. Populated from the
* d:user-settings Nostr event (global.agent.providers) on login.
* If no providers are loaded, a single default provider is seeded
* from the legacy agent_llm_base_url / agent_llm_api_key fields. */
s->agent_provider_count = 0;
s->agent_active_provider = -1;
s->agent_active_provider_name[0] = '\0';
memset(s->agent_providers, 0, sizeof(s->agent_providers));
}
/* ── Parsing helpers ──────────────────────────────────────────────── */
@@ -235,6 +267,82 @@ void settings_load_user(void) {
val = db_kv_get("search_engine");
if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val);
/* Agent LLM provider settings — these are the "resolved" values
* (active provider's base_url + api_key + selected model). They are
* populated from db_kv here for local persistence, and overwritten
* by settings_sync_merge_from_nostr() from global.agent.providers +
* sovereign_browser.agent when a Nostr event is available. */
val = db_kv_get("agent.llm_base_url");
if (val) snprintf(g_settings.agent_llm_base_url, sizeof(g_settings.agent_llm_base_url), "%s", val);
val = db_kv_get("agent.llm_api_key");
if (val) snprintf(g_settings.agent_llm_api_key, sizeof(g_settings.agent_llm_api_key), "%s", val);
val = db_kv_get("agent.llm_model");
if (val) snprintf(g_settings.agent_llm_model, sizeof(g_settings.agent_llm_model), "%s", val);
val = db_kv_get("agent.llm_system_prompt");
if (val) snprintf(g_settings.agent_llm_system_prompt, sizeof(g_settings.agent_llm_system_prompt), "%s", val);
val = db_kv_get("agent.max_iterations");
if (val) g_settings.agent_max_iterations = atoi(val);
/* Sovereign Browser Skill fields. The template mirrors
* agent_llm_system_prompt for backward compatibility — if the
* legacy key is set but the new skill_template key is not, the
* template falls back to the legacy value (or the default). */
val = db_kv_get("agent.skill_name");
if (val) snprintf(g_settings.agent_skill_name,
sizeof(g_settings.agent_skill_name), "%s", val);
val = db_kv_get("agent.skill_description");
if (val) snprintf(g_settings.agent_skill_description,
sizeof(g_settings.agent_skill_description), "%s", val);
val = db_kv_get("agent.skill_template");
if (val && val[0]) {
snprintf(g_settings.agent_skill_template,
sizeof(g_settings.agent_skill_template), "%s", val);
} else if (g_settings.agent_llm_system_prompt[0]) {
/* Migrate from the legacy system_prompt field. */
snprintf(g_settings.agent_skill_template,
sizeof(g_settings.agent_skill_template), "%s",
g_settings.agent_llm_system_prompt);
}
/* Keep the legacy alias in sync (truncates to 4096 safely). */
if (g_settings.agent_skill_template[0]) {
g_strlcpy(g_settings.agent_llm_system_prompt,
g_settings.agent_skill_template,
sizeof(g_settings.agent_llm_system_prompt));
}
val = db_kv_get("agent.skill_requires_tools");
if (val) snprintf(g_settings.agent_skill_requires_tools,
sizeof(g_settings.agent_skill_requires_tools), "%s", val);
/* If no provider catalog was loaded from Nostr (agent_provider_count
* == 0), seed a single "default" provider from the resolved
* agent_llm_base_url / agent_llm_api_key so the agents config page
* has something to show. */
if (g_settings.agent_provider_count == 0) {
agent_provider_t *p = &g_settings.agent_providers[0];
memset(p, 0, sizeof(*p));
snprintf(p->name, sizeof(p->name), "default");
snprintf(p->base_url, sizeof(p->base_url), "%s",
g_settings.agent_llm_base_url);
snprintf(p->api_key, sizeof(p->api_key), "%s",
g_settings.agent_llm_api_key);
if (g_settings.agent_llm_model[0]) {
snprintf(p->models[0], sizeof(p->models[0]), "%s",
g_settings.agent_llm_model);
p->model_count = 1;
}
g_settings.agent_provider_count = 1;
g_settings.agent_active_provider = 0;
snprintf(g_settings.agent_active_provider_name,
sizeof(g_settings.agent_active_provider_name), "default");
}
}
void settings_load(void) {
@@ -309,6 +417,23 @@ void settings_save_user(void) {
db_kv_set("bootstrap_relays", g_settings.bootstrap_relays);
db_kv_set("search_engine", g_settings.search_engine);
/* Agent LLM provider settings — resolved values (active provider). */
db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url);
db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key);
db_kv_set("agent.llm_model", g_settings.agent_llm_model);
db_kv_set("agent.llm_system_prompt", g_settings.agent_skill_template);
db_kv_set("agent.provider", g_settings.agent_active_provider_name);
snprintf(buf, sizeof(buf), "%d", g_settings.agent_max_iterations);
db_kv_set("agent.max_iterations", buf);
/* Sovereign Browser Skill fields. */
db_kv_set("agent.skill_name", g_settings.agent_skill_name);
db_kv_set("agent.skill_description", g_settings.agent_skill_description);
db_kv_set("agent.skill_template", g_settings.agent_skill_template);
db_kv_set("agent.skill_requires_tools",
g_settings.agent_skill_requires_tools);
}
void settings_save(void) {

View File

@@ -28,6 +28,35 @@ extern "C" {
"wss://relay.damus.io"
#define SETTINGS_SEARCH_ENGINE_MAX 64
#define SETTINGS_SEARCH_ENGINE_DEFAULT "duckduckgo"
#define SETTINGS_AGENT_LLM_BASE_URL_DEFAULT "https://api.ppq.ai"
#define SETTINGS_AGENT_LLM_MODEL_DEFAULT "gpt-4o"
#define SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT 100
#define SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT \
"You are an AI assistant embedded in a web browser. You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page content, then interact with elements using refs (e.g. @e1). You can read and write files and run shell commands. Be concise in your responses. When a task is complete, summarize what you did."
/* Sovereign Browser Skill — the default skill presented on the config
* page and chat page. The template is the system prompt; the other
* fields describe the skill for publishing as kind 31123. */
#define SETTINGS_AGENT_SKILL_NAME_DEFAULT "Sovereign Browser Default"
#define SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT "Default agent skill for sovereign_browser"
#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT "browser, fs, shell"
#define SETTINGS_AGENT_SKILL_TEMPLATE_MAX 8192
#define SETTINGS_AGENT_SKILL_NAME_MAX 128
#define SETTINGS_AGENT_SKILL_DESCRIPTION_MAX 512
#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX 256
#define SETTINGS_AGENT_MAX_PROVIDERS 16
#define SETTINGS_AGENT_MAX_MODELS 64
#define SETTINGS_AGENT_PROVIDER_NAME_MAX 64
/* A single LLM provider entry in the shared global.agent.providers catalog. */
typedef struct {
char name[SETTINGS_AGENT_PROVIDER_NAME_MAX]; /* e.g. "ppq", "ollama-local" */
char base_url[512];
char api_key[512];
char models[SETTINGS_AGENT_MAX_MODELS][128]; /* list of known model ids */
int model_count;
} agent_provider_t;
typedef struct {
gboolean restore_session; /* restore open tabs on next launch */
@@ -49,6 +78,29 @@ typedef struct {
int inspector_y; /* inspector detached window Y (-1 = default) */
int inspector_w; /* inspector detached window width (-1 = default) */
int inspector_h; /* inspector detached window height (-1 = default) */
/* Agent LLM provider settings — "resolved" values for the active
* provider + selected model. Populated from global.agent.providers
* (base_url, api_key) and sovereign_browser.agent (model). */
char agent_llm_base_url[512]; /* e.g. "https://api.openai.com/v1" */
char agent_llm_api_key[512]; /* bearer token (may be empty for local servers) */
char agent_llm_model[128]; /* e.g. "gpt-4o", "llama3.1" */
char agent_llm_system_prompt[4096]; /* legacy alias for agent_skill_template (kept in sync) */
int agent_max_iterations; /* max tool-call loop iterations (default 100) */
/* Sovereign Browser Skill — the default skill presented on the
* config page and chat page. The template is the system prompt
* (mirrored into agent_llm_system_prompt for backward compat).
* Published as kind 31123 when the user clicks "Save as Skill". */
char agent_skill_name[SETTINGS_AGENT_SKILL_NAME_MAX];
char agent_skill_description[SETTINGS_AGENT_SKILL_DESCRIPTION_MAX];
char agent_skill_template[SETTINGS_AGENT_SKILL_TEMPLATE_MAX];
char agent_skill_requires_tools[SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX];
/* Multi-provider catalog (shared via global.agent in d:user-settings). */
agent_provider_t agent_providers[SETTINGS_AGENT_MAX_PROVIDERS];
int agent_provider_count;
int agent_active_provider; /* index into agent_providers, -1 if none */
char agent_active_provider_name[SETTINGS_AGENT_PROVIDER_NAME_MAX];
} browser_settings_t;
/*

View File

@@ -2,15 +2,45 @@
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs whitelisted, device-independent settings + keyboard shortcut
* bindings across all devices the user logs in on. Uses a single kind
* 30078 addressable event with d-tag "sovereign_browser". The content is
* NIP-44 encrypted (self-to-self) JSON.
* bindings across all devices the user logs in on. Uses a single shared
* kind 30078 addressable event with d-tag "user-settings" (the same event
* used by ~/lt/client and ~/lt/didactyl). The content is NIP-44 encrypted
* (self-to-self) JSON with a "global" namespace (shared agent provider
* catalog under global.agent) and per-app namespaces (sovereign_browser,
* client, didactyl, ...).
*
* sovereign_browser writes only the "sovereign_browser" and "global.agent"
* namespaces. Other apps' namespaces are preserved via read-modify-write:
* on publish, the current d:user-settings event is fetched from the SQLite
* cache (or relays), decrypted, the sovereign_browser + global.agent
* namespaces are patched, and the result is re-encrypted and published.
*
* The publish path reuses the same pattern as bookmarks.c's
* publish_directory(): serialize → NIP-44 encrypt → sign → store in
* SQLite → publish to bootstrap relays.
*
* settings_sync_publish() is debounced (500ms) so rapid edits coalesce.
*
* Schema (v2):
* {
* "v": 2,
* "updatedAt": <ts>,
* "global": {
* "agent": {
* "providers": [ {name, base_url, api_key, models}, ... ],
* "favorites": [ "model-id", ... ]
* },
* ... (zaps, ui, relays, ... — preserved, not touched by us)
* },
* "sovereign_browser": {
* "agent": { provider, model, system_prompt, max_iterations },
* "new_tab_url": "", "tab_bar_position": 0, "max_tabs": 50,
* "bootstrap_relays": "...", "search_engine": "duckduckgo",
* "theme_dark": false, "shortcuts": { ... }
* },
* "client": { ... }, // preserved
* "didactyl": { ... } // preserved
* }
*/
#include "settings_sync.h"
@@ -38,11 +68,12 @@ static guint g_publish_timeout_id = 0;
/* db_kv key for the last-synced timestamp. */
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
/* ── Whitelist of syncable settings ─────────────────────────────────── */
/* ── Whitelist of syncable browser settings ─────────────────────────── */
/* Keys from the settings struct that should be synced. Device-specific
* settings (agent port, allowed origins, session restore, security
* toggles) are intentionally excluded. */
/* Keys from the settings struct that should be synced under the
* sovereign_browser namespace. Device-specific settings (agent port,
* allowed origins, session restore, security toggles) are intentionally
* excluded. */
static const char *g_sync_setting_keys[] = {
"new_tab_url",
"tab_bar_position",
@@ -111,33 +142,197 @@ static char *decrypt_content(const char *ciphertext) {
return plaintext;
}
/* ── Serialization ──────────────────────────────────────────────────── */
/* Check whether a kind 30078 event has a given d-tag value.
* Returns 1 if matched, 0 otherwise. */
static int event_has_d_tag(const cJSON *event, const char *d_value) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
cJSON_IsString(t1) && strcmp(t1->valuestring, d_value) == 0) {
return 1;
}
}
return 0;
}
/* Serialize all syncable settings + shortcuts to a JSON object:
* {"settings":{...}, "shortcuts":{...}}
/* ── Serialization: build sovereign_browser namespace ───────────────── */
/* Build the "sovereign_browser" namespace object from current settings.
* Returns a newly allocated cJSON object (caller must delete). */
static cJSON *serialize_payload(void) {
cJSON *root = cJSON_CreateObject();
/* Settings whitelist. */
cJSON *settings_obj = cJSON_CreateObject();
const browser_settings_t *s = settings_get();
(void)s; /* read via db_kv_get to get string values uniformly */
static cJSON *build_sovereign_browser_namespace(void) {
cJSON *sb = cJSON_CreateObject();
/* Browser settings whitelist (read via db_kv_get for uniform string
* values — same approach as the original serialize_payload). */
for (int i = 0; i < g_sync_setting_count; i++) {
const char *key = g_sync_setting_keys[i];
const char *val = db_kv_get(key);
if (val) {
cJSON_AddStringToObject(settings_obj, key, val);
cJSON_AddStringToObject(sb, key, val);
}
}
cJSON_AddItemToObject(root, "settings", settings_obj);
/* Per-app agent config: provider, model, system_prompt (legacy),
* max_iterations, and the Sovereign Browser Skill fields. These are
* stored in db_kv under the agent.* keys by handle_agents_set. */
cJSON *agent = cJSON_CreateObject();
const char *provider = db_kv_get("agent.provider");
const char *model = db_kv_get("agent.llm_model");
const char *prompt = db_kv_get("agent.llm_system_prompt");
const char *maxit = db_kv_get("agent.max_iterations");
const char *sk_name = db_kv_get("agent.skill_name");
const char *sk_desc = db_kv_get("agent.skill_description");
const char *sk_tmpl = db_kv_get("agent.skill_template");
const char *sk_tools = db_kv_get("agent.skill_requires_tools");
cJSON_AddStringToObject(agent, "provider",
provider ? provider : "");
cJSON_AddStringToObject(agent, "model",
model ? model : "");
cJSON_AddStringToObject(agent, "system_prompt",
prompt ? prompt : "");
cJSON_AddNumberToObject(agent, "max_iterations",
maxit ? (double)atol(maxit)
: (double)SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT);
cJSON_AddStringToObject(agent, "skill_name",
sk_name ? sk_name : "");
cJSON_AddStringToObject(agent, "skill_description",
sk_desc ? sk_desc : "");
cJSON_AddStringToObject(agent, "skill_template",
sk_tmpl ? sk_tmpl : "");
cJSON_AddStringToObject(agent, "skill_requires_tools",
sk_tools ? sk_tools : "");
cJSON_AddItemToObject(sb, "agent", agent);
/* Shortcuts. */
cJSON *shortcuts_obj = shortcuts_serialize();
cJSON_AddItemToObject(root, "shortcuts", shortcuts_obj);
cJSON_AddItemToObject(sb, "shortcuts", shortcuts_obj);
return root;
return sb;
}
/* Build the "global.agent" namespace object from the in-memory provider
* catalog. Returns a newly allocated cJSON object (caller must delete). */
static cJSON *build_global_agent_namespace(void) {
cJSON *agent = cJSON_CreateObject();
const browser_settings_t *s = settings_get();
/* providers array */
cJSON *providers = cJSON_CreateArray();
for (int i = 0; i < s->agent_provider_count; i++) {
const agent_provider_t *p = &s->agent_providers[i];
cJSON *pobj = cJSON_CreateObject();
cJSON_AddStringToObject(pobj, "name", p->name);
cJSON_AddStringToObject(pobj, "base_url", p->base_url);
cJSON_AddStringToObject(pobj, "api_key", p->api_key);
cJSON *models = cJSON_CreateArray();
for (int m = 0; m < p->model_count; m++) {
cJSON_AddItemToArray(models, cJSON_CreateString(p->models[m]));
}
cJSON_AddItemToObject(pobj, "models", models);
cJSON_AddItemToArray(providers, pobj);
}
cJSON_AddItemToObject(agent, "providers", providers);
/* favorites — for now, just the currently selected model. */
cJSON *favorites = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(agent, "favorites", favorites);
return agent;
}
/* ── Read-modify-write: fetch current event, patch, return payload ──── */
/* Fetch the current d:user-settings event from the SQLite cache.
* Returns a newly allocated cJSON event (caller must delete), or NULL. */
static cJSON *fetch_current_user_settings_event(void) {
if (g_pubkey[0] == '\0') return NULL;
/* db_get_latest_event returns the newest event of a given kind for
* the pubkey. We then verify the d-tag is "user-settings". */
cJSON *events = db_get_events(g_pubkey, SETTINGS_SYNC_KIND, 32);
if (events == NULL) return NULL;
int n = cJSON_GetArraySize(events);
cJSON *found = NULL;
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_d_tag(ev, SETTINGS_SYNC_D_TAG)) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
cJSON_Delete(events);
return found;
}
/* Decrypt the content of an event and parse it as a JSON object.
* Returns a newly allocated cJSON object (caller must delete), or NULL. */
static cJSON *decrypt_event_payload(const cJSON *event) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!cJSON_IsString(content)) return NULL;
char *plaintext = decrypt_content(content->valuestring);
if (plaintext == NULL) return NULL;
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
return payload;
}
/* Build the full payload to publish, using read-modify-write:
* 1. Fetch the current d:user-settings event from SQLite cache.
* 2. Decrypt + parse it (or start a fresh v2 object if none).
* 3. Patch the "sovereign_browser" and "global.agent" namespaces.
* 4. Return the patched payload (caller must delete).
*
* Other namespaces (client, didactyl, global.zaps, global.ui, ...) are
* preserved untouched. */
static cJSON *build_publish_payload(void) {
cJSON *payload = NULL;
cJSON *current = fetch_current_user_settings_event();
if (current != NULL) {
payload = decrypt_event_payload(current);
cJSON_Delete(current);
}
if (payload == NULL || !cJSON_IsObject(payload)) {
/* No existing event (or decrypt failed) — start fresh. */
if (payload) cJSON_Delete(payload);
payload = cJSON_CreateObject();
cJSON_AddNumberToObject(payload, "v", 2);
}
/* Ensure "global" object exists. */
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
if (!cJSON_IsObject(global)) {
global = cJSON_CreateObject();
cJSON_AddItemToObject(payload, "global", global);
}
/* Patch global.agent (replace entirely with our view). */
cJSON *old_agent = cJSON_GetObjectItemCaseSensitive(global, "agent");
if (old_agent) cJSON_DeleteItemFromObjectCaseSensitive(global, "agent");
cJSON *new_agent = build_global_agent_namespace();
cJSON_AddItemToObject(global, "agent", new_agent);
/* Patch sovereign_browser namespace (replace entirely). */
cJSON *old_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (old_sb) cJSON_DeleteItemFromObjectCaseSensitive(payload, "sovereign_browser");
cJSON *new_sb = build_sovereign_browser_namespace();
cJSON_AddItemToObject(payload, "sovereign_browser", new_sb);
/* Update top-level updatedAt. */
cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "updatedAt");
if (ts) cJSON_DeleteItemFromObjectCaseSensitive(payload, "updatedAt");
cJSON_AddNumberToObject(payload, "updatedAt", (double)time(NULL));
return payload;
}
/* ── Publish (debounced) ────────────────────────────────────────────── */
@@ -146,8 +341,13 @@ static cJSON *serialize_payload(void) {
static void do_publish(void) {
if (!g_have_signer) return;
/* Serialize. */
cJSON *payload = serialize_payload();
/* Build the payload via read-modify-write. */
cJSON *payload = build_publish_payload();
if (payload == NULL) {
g_printerr("[settings_sync] Failed to build payload\n");
return;
}
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
@@ -160,7 +360,7 @@ static void do_publish(void) {
free(json);
if (ciphertext == NULL) return;
/* Build tags: [["d", "sovereign_browser"], ["client", "sovereign_browser"]] */
/* Build tags: [["d", "user-settings"], ["client", "sovereign_browser"]] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag = cJSON_CreateArray();
@@ -203,8 +403,9 @@ static void do_publish(void) {
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[settings_sync] Published kind %d to %d/%d relays\n",
SETTINGS_SYNC_KIND, success_count, relay_count);
g_print("[settings_sync] Published kind %d (d:%s) to %d/%d relays\n",
SETTINGS_SYNC_KIND, SETTINGS_SYNC_D_TAG,
success_count, relay_count);
} else {
g_print("[settings_sync] No relays configured, event stored locally\n");
}
@@ -221,6 +422,267 @@ static gboolean publish_timeout_cb(gpointer data) {
return G_SOURCE_REMOVE;
}
/* ── Merge helpers ──────────────────────────────────────────────────── */
/* Merge the "sovereign_browser" namespace from a decrypted payload into
* local db_kv + in-memory settings. */
static void merge_sovereign_browser_namespace(const cJSON *sb) {
if (!cJSON_IsObject(sb)) return;
/* Browser settings whitelist. */
cJSON *item;
cJSON_ArrayForEach(item, sb) {
if (!cJSON_IsString(item)) continue;
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
/* Per-app agent config. Only overwrite local values from Nostr if
* the local value is empty — this prevents a corrupted Nostr event
* from overwriting good local data. The local DB is the source of
* truth for per-app settings; Nostr is only used to bootstrap new
* devices. */
cJSON *agent = cJSON_GetObjectItemCaseSensitive(sb, "agent");
if (cJSON_IsObject(agent)) {
cJSON *j;
const char *existing;
j = cJSON_GetObjectItemCaseSensitive(agent, "provider");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.provider");
if (!existing || !existing[0])
db_kv_set("agent.provider", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "model");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.llm_model");
if (!existing || !existing[0])
db_kv_set("agent.llm_model", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "system_prompt");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.llm_system_prompt");
if (!existing || !existing[0])
db_kv_set("agent.llm_system_prompt", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "max_iterations");
if (cJSON_IsNumber(j) && (int)j->valuedouble > 0) {
existing = db_kv_get("agent.max_iterations");
if (!existing || !existing[0] || atoi(existing) <= 0) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", (int)j->valuedouble);
db_kv_set("agent.max_iterations", buf);
}
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_name");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_name");
if (!existing || !existing[0])
db_kv_set("agent.skill_name", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_description");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_description");
if (!existing || !existing[0])
db_kv_set("agent.skill_description", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_template");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_template");
if (!existing || !existing[0])
db_kv_set("agent.skill_template", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_requires_tools");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_requires_tools");
if (!existing || !existing[0])
db_kv_set("agent.skill_requires_tools", j->valuestring);
}
}
/* Shortcuts. */
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(sb, "shortcuts");
if (cJSON_IsObject(shortcuts_obj)) {
shortcuts_merge_from_json(shortcuts_obj);
}
/* Reload settings from db_kv into the in-memory singleton. */
settings_load();
}
/* Merge the "global.agent" namespace from a decrypted payload into the
* in-memory provider catalog + resolved agent_llm_* fields. */
static void merge_global_agent_namespace(const cJSON *agent_obj) {
if (!cJSON_IsObject(agent_obj)) return;
browser_settings_t *s = settings_get_mutable();
/* providers array — only merge from Nostr if the local provider
* catalog is empty. This prevents a corrupted Nostr event from
* overwriting good local provider data. The local DB is the source
* of truth; Nostr is only used to bootstrap new devices. */
cJSON *providers = cJSON_GetObjectItemCaseSensitive(agent_obj, "providers");
if (cJSON_IsArray(providers) && s->agent_provider_count == 0) {
s->agent_provider_count = 0;
int n = cJSON_GetArraySize(providers);
if (n > SETTINGS_AGENT_MAX_PROVIDERS) n = SETTINGS_AGENT_MAX_PROVIDERS;
for (int i = 0; i < n; i++) {
cJSON *p = cJSON_GetArrayItem(providers, i);
if (!cJSON_IsObject(p)) continue;
agent_provider_t *dst = &s->agent_providers[i];
memset(dst, 0, sizeof(*dst));
cJSON *j;
j = cJSON_GetObjectItemCaseSensitive(p, "name");
if (cJSON_IsString(j)) {
snprintf(dst->name, sizeof(dst->name), "%s", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(p, "base_url");
if (cJSON_IsString(j)) {
snprintf(dst->base_url, sizeof(dst->base_url), "%s",
j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(p, "api_key");
if (cJSON_IsString(j)) {
snprintf(dst->api_key, sizeof(dst->api_key), "%s",
j->valuestring);
}
cJSON *models = cJSON_GetObjectItemCaseSensitive(p, "models");
if (cJSON_IsArray(models)) {
int mn = cJSON_GetArraySize(models);
if (mn > SETTINGS_AGENT_MAX_MODELS) mn = SETTINGS_AGENT_MAX_MODELS;
for (int m = 0; m < mn; m++) {
cJSON *mj = cJSON_GetArrayItem(models, m);
if (cJSON_IsString(mj)) {
snprintf(dst->models[m], sizeof(dst->models[m]),
"%s", mj->valuestring);
dst->model_count++;
}
}
}
s->agent_provider_count++;
}
}
/* Resolve the active provider from agent.provider (in db_kv or the
* sovereign_browser namespace) and populate the resolved
* agent_llm_base_url / agent_llm_api_key fields. */
const char *provider_name = db_kv_get("agent.provider");
s->agent_active_provider = -1;
s->agent_active_provider_name[0] = '\0';
if (provider_name && provider_name[0]) {
for (int i = 0; i < s->agent_provider_count; i++) {
if (strcmp(s->agent_providers[i].name, provider_name) == 0) {
s->agent_active_provider = i;
snprintf(s->agent_active_provider_name,
sizeof(s->agent_active_provider_name), "%s",
provider_name);
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
"%s", s->agent_providers[i].base_url);
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
"%s", s->agent_providers[i].api_key);
break;
}
}
}
/* If no active provider was matched but we have providers, fall back
* to the first provider so the resolved base_url/api_key are usable. */
if (s->agent_active_provider < 0 && s->agent_provider_count > 0) {
s->agent_active_provider = 0;
snprintf(s->agent_active_provider_name,
sizeof(s->agent_active_provider_name), "%s",
s->agent_providers[0].name);
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
"%s", s->agent_providers[0].base_url);
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
"%s", s->agent_providers[0].api_key);
}
/* The model comes from the per-app sovereign_browser.agent.model
* (already in db_kv via merge_sovereign_browser_namespace, or from
* the local settings). settings_load() will pick it up. */
}
/* Migrate the old d:sovereign_browser event format (top-level "settings"
* and "shortcuts" keys, no "sovereign_browser" namespace) into the new
* v2 schema. Returns a newly allocated cJSON payload (caller must delete),
* or NULL if the payload is not the old format. */
static cJSON *migrate_legacy_payload(const cJSON *payload) {
if (payload == NULL || !cJSON_IsObject(payload)) return NULL;
/* The old format has a top-level "settings" object and no
* "sovereign_browser" namespace. */
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
cJSON *new_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (!cJSON_IsObject(old_settings) || cJSON_IsObject(new_sb)) {
return NULL; /* not the old format */
}
/* Build a new v2 payload. */
cJSON *v2 = cJSON_CreateObject();
cJSON_AddNumberToObject(v2, "v", 2);
cJSON_AddNumberToObject(v2, "updatedAt", (double)time(NULL));
/* global.agent — seed from the old agent.* keys if present. */
cJSON *global = cJSON_CreateObject();
cJSON *agent = cJSON_CreateObject();
cJSON *providers = cJSON_CreateArray();
/* Build a single provider from the old agent settings. */
const browser_settings_t *s = settings_get();
cJSON *pobj = cJSON_CreateObject();
cJSON_AddStringToObject(pobj, "name", "default");
cJSON_AddStringToObject(pobj, "base_url", s->agent_llm_base_url);
cJSON_AddStringToObject(pobj, "api_key", s->agent_llm_api_key);
cJSON *models = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(models, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(pobj, "models", models);
cJSON_AddItemToArray(providers, pobj);
cJSON_AddItemToObject(agent, "providers", providers);
cJSON *favorites = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(agent, "favorites", favorites);
cJSON_AddItemToObject(global, "agent", agent);
cJSON_AddItemToObject(v2, "global", global);
/* sovereign_browser namespace — copy old settings + shortcuts. */
cJSON *sb_ns = cJSON_Duplicate(old_settings, 1);
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(old_shortcuts)) {
cJSON *sc_copy = cJSON_Duplicate(old_shortcuts, 1);
cJSON_AddItemToObject(sb_ns, "shortcuts", sc_copy);
}
/* Add per-app agent config from the old resolved fields. */
cJSON *sb_agent = cJSON_CreateObject();
cJSON_AddStringToObject(sb_agent, "provider", "default");
cJSON_AddStringToObject(sb_agent, "model", s->agent_llm_model);
cJSON_AddStringToObject(sb_agent, "system_prompt", s->agent_llm_system_prompt);
cJSON_AddNumberToObject(sb_agent, "max_iterations",
(double)s->agent_max_iterations);
cJSON_AddStringToObject(sb_agent, "skill_name", s->agent_skill_name);
cJSON_AddStringToObject(sb_agent, "skill_description",
s->agent_skill_description);
cJSON_AddStringToObject(sb_agent, "skill_template", s->agent_skill_template);
cJSON_AddStringToObject(sb_agent, "skill_requires_tools",
s->agent_skill_requires_tools);
cJSON_AddItemToObject(sb_ns, "agent", sb_agent);
cJSON_AddItemToObject(v2, "sovereign_browser", sb_ns);
g_print("[settings_sync] Migrated legacy d:%s payload to v2 schema\n",
SETTINGS_SYNC_D_TAG_LEGACY);
return v2;
}
/* ── Public API ─────────────────────────────────────────────────────── */
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex) {
@@ -258,37 +720,29 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
return -1;
}
/* Verify the d-tag is "sovereign_browser". */
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return -1;
int is_ours = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
cJSON_IsString(t1) &&
strcmp(t1->valuestring, SETTINGS_SYNC_D_TAG) == 0) {
is_ours = 1;
break;
}
}
if (!is_ours) return -1;
/* Verify the d-tag is "user-settings" (new) or "sovereign_browser"
* (legacy — will be migrated). */
int is_new = event_has_d_tag(event, SETTINGS_SYNC_D_TAG);
int is_legacy = event_has_d_tag(event, SETTINGS_SYNC_D_TAG_LEGACY);
if (!is_new && !is_legacy) return -1;
/* Get the event's created_at. */
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!cJSON_IsNumber(created_at)) return -1;
long event_ts = (long)created_at->valuedouble;
/* Compare to our last-synced timestamp. */
/* Compare to our last-synced timestamp. We only skip the merge if the
* Nostr event is STRICTLY OLDER than our last sync — this prevents
* rolling back to stale data. If the timestamps are equal, we still
* merge because the local DB might be missing fields that are in the
* Nostr event (e.g., the API key was saved from another device and
* the local DB was created from a partial sync with the same timestamp). */
const char *ts_str = db_kv_get(SYNC_TS_KEY);
long local_ts = 0;
if (ts_str) local_ts = atol(ts_str);
if (event_ts <= local_ts) {
g_print("[settings_sync] Nostr event (%ld) not newer than local (%ld), "
if (event_ts < local_ts) {
g_print("[settings_sync] Nostr event (%ld) older than local (%ld), "
"skipping merge\n", event_ts, local_ts);
return 0;
}
@@ -309,28 +763,51 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
return -1;
}
/* Merge settings. */
cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings");
if (cJSON_IsObject(settings_obj)) {
cJSON *item;
cJSON_ArrayForEach(item, settings_obj) {
if (!cJSON_IsString(item)) continue;
/* Only merge whitelisted keys. */
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
/* If this is a legacy event, migrate the payload to v2. */
if (is_legacy) {
cJSON *migrated = migrate_legacy_payload(payload);
if (migrated) {
cJSON_Delete(payload);
payload = migrated;
}
/* Reload settings from db_kv into the in-memory singleton. */
settings_load();
}
/* Merge shortcuts. */
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(shortcuts_obj)) {
shortcuts_merge_from_json(shortcuts_obj);
/* Merge global.agent (provider catalog). */
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
if (cJSON_IsObject(global)) {
cJSON *agent_obj = cJSON_GetObjectItemCaseSensitive(global, "agent");
if (cJSON_IsObject(agent_obj)) {
merge_global_agent_namespace(agent_obj);
}
}
/* Merge sovereign_browser namespace. */
cJSON *sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (cJSON_IsObject(sb)) {
merge_sovereign_browser_namespace(sb);
} else if (is_legacy) {
/* Legacy payload that wasn't migrated (migrate_legacy_payload
* returned NULL because it didn't have the old "settings" key).
* Fall back to merging top-level "settings" + "shortcuts" as the
* old code did. */
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
if (cJSON_IsObject(old_settings)) {
cJSON *item;
cJSON_ArrayForEach(item, old_settings) {
if (!cJSON_IsString(item)) continue;
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
settings_load();
}
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(old_shortcuts)) {
shortcuts_merge_from_json(old_shortcuts);
}
}
/* Update the sync timestamp. */
@@ -338,7 +815,8 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
db_kv_set(SYNC_TS_KEY, ts_buf);
g_print("[settings_sync] Merged settings from Nostr event (ts=%ld)\n",
g_print("[settings_sync] Merged settings from Nostr event (d:%s, ts=%ld)\n",
is_legacy ? SETTINGS_SYNC_D_TAG_LEGACY : SETTINGS_SYNC_D_TAG,
event_ts);
cJSON_Delete(payload);

View File

@@ -2,14 +2,19 @@
* settings_sync.h — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs user-configurable, device-independent settings across all devices
* the user logs in on. Uses a single kind 30078 addressable event with
* d-tag "sovereign_browser". The content is NIP-44 encrypted (self-to-self)
* JSON containing whitelisted settings + all keyboard shortcut bindings.
* the user logs in on. Uses a single shared kind 30078 addressable event
* with d-tag "user-settings" (the same event used by ~/lt/client and
* ~/lt/didactyl). The content is NIP-44 encrypted (self-to-self) JSON
* with a "global" namespace (shared agent provider catalog) and per-app
* namespaces (e.g. "sovereign_browser", "client", "didactyl").
*
* sovereign_browser writes only the "sovereign_browser" and "global.agent"
* namespaces; other apps' namespaces are preserved via read-modify-write.
*
* Device-specific settings (agent port, allowed origins, session restore,
* security toggles) are NOT synced — they stay local only.
*
* See plans/keyboard-shortcuts.md for the full design.
* See plans/cross-project-agent-sync.md for the full design.
*/
#ifndef SETTINGS_SYNC_H
@@ -24,8 +29,12 @@
extern "C" {
#endif
/* The d-tag identifier used for our kind 30078 event. */
#define SETTINGS_SYNC_D_TAG "sovereign_browser"
/* The d-tag identifier used for the shared kind 30078 user-settings event.
* This is shared with ~/lt/client and ~/lt/didactyl. */
#define SETTINGS_SYNC_D_TAG "user-settings"
/* The legacy d-tag used before the migration to the shared event. */
#define SETTINGS_SYNC_D_TAG_LEGACY "sovereign_browser"
/* The Nostr kind for arbitrary custom app data (NIP-78). */
#define SETTINGS_SYNC_KIND 30078
@@ -59,7 +68,8 @@ void settings_sync_publish(void);
* overwrites local db_kv values and reloads in-memory bindings.
*
* event — a cJSON object representing a kind 30078 event with
* d-tag "sovereign_browser"
* d-tag "user-settings" (or the legacy "sovereign_browser"
* d-tag, which is migrated to the new format on merge).
*
* Returns 0 on success, -1 on error / not applicable.
*/

View File

@@ -87,6 +87,10 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"toggle_inspector", "Toggle inspector",
"Show or hide the WebKit Web Inspector", "<Control><Shift>i"
},
[SHORTCUT_TOGGLE_SIDEBAR] = {
"toggle_sidebar", "Toggle agent sidebar",
"Show or hide the agent chat sidebar", "<Control><Shift>a"
},
};
/* ── In-memory parsed bindings ──────────────────────────────────────── */

View File

@@ -43,6 +43,7 @@ typedef enum {
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,
SHORTCUT_TOGGLE_SIDEBAR,
SHORTCUT_COUNT
} shortcut_action_t;

View File

@@ -16,6 +16,7 @@
#include "version.h"
#include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */
#include "db.h"
#include "agent_chat.h" /* agent_chat_route_input() for ";" URL-bar shortcut */
#include <string.h>
#include <strings.h> /* strcasecmp */
@@ -49,6 +50,7 @@ extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
extern void on_menu_agent(GtkMenuItem *item, gpointer data);
/* Key press handler defined in main.c — connected to each webview so
* keyboard shortcuts are caught before WebKit consumes the event. */
@@ -840,6 +842,12 @@ static GtkWidget *tab_manager_new_window(const char *url,
/* Show the tab widgets before switching to it. */
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* Re-hide the sidebar container after show_all (see
* tab_manager_new_tab for the rationale — Issue 2). */
if (tab->sidebar_container) {
gtk_widget_hide(tab->sidebar_container);
}
gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), page_num);
/* Window lifecycle: focus-in updates the active window/notebook
@@ -913,6 +921,18 @@ static GtkWidget *build_tab_label(tab_info_t *tab) {
static void on_url_activate(GtkEntry *entry, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
const char *text = gtk_entry_get_text(entry);
/* Agent chat shortcut: "; <message>" routes to the embedded agent. */
if (text[0] == ';') {
const char *msg = text + 1;
/* Skip leading whitespace after the semicolon. */
while (*msg == ' ' || *msg == '\t') msg++;
agent_chat_route_input(*msg ? msg : NULL);
/* Clear the URL bar after sending to agent. */
gtk_entry_set_text(entry, "");
return;
}
char *url = normalize_url(text);
if (url != NULL) {
webkit_web_view_load_uri(tab->webview, url);
@@ -1288,6 +1308,12 @@ static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
tab_manager_toggle_inspector();
}
static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_toggle_sidebar();
}
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
@@ -1686,11 +1712,21 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
G_CALLBACK(on_menu_inspector), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_sidebar = gtk_menu_item_new_with_label("Toggle Agent Sidebar");
g_signal_connect(item_sidebar, "activate",
G_CALLBACK(on_menu_toggle_sidebar), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_sidebar);
GtkWidget *item_profile = gtk_menu_item_new_with_label("Profile");
g_signal_connect(item_profile, "activate",
G_CALLBACK(on_menu_profile), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_profile);
GtkWidget *item_agent = gtk_menu_item_new_with_label("Agent Setup…");
g_signal_connect(item_agent, "activate",
G_CALLBACK(on_menu_agent), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_agent);
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
g_signal_connect(item_settings, "activate",
G_CALLBACK(on_menu_settings), g_window);
@@ -1965,8 +2001,33 @@ static tab_info_t *tab_create(const char *url) {
* display servers, resulting in a blank page. */
gtk_widget_set_vexpand(GTK_WIDGET(tab->webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->webview), TRUE);
gtk_box_pack_start(GTK_BOX(tab->page), GTK_WIDGET(tab->webview),
TRUE, TRUE, 0);
/* Build a horizontal GtkPaned: left = sidebar container, right =
* main webview. Both children are always packed (no add/remove
* which caused a crash). When hidden, the position is set to 0
* and the sidebar widget is hidden — the divider disappears at
* position 0. When shown, the position is set to 280 and the
* sidebar is shown — the divider appears and is resizable. */
tab->paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_vexpand(tab->paned, TRUE);
gtk_widget_set_hexpand(tab->paned, TRUE);
/* Sidebar container — packed as first (left) pane. Hidden by
* default. No size request (the paned position controls width). */
tab->sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_paned_pack1(GTK_PANED(tab->paned), tab->sidebar_container,
FALSE, FALSE);
gtk_widget_hide(tab->sidebar_container);
tab->sidebar_visible = FALSE;
tab->sidebar_webview = NULL;
/* Main webview — packed as second (right) pane. */
gtk_paned_pack2(GTK_PANED(tab->paned), GTK_WIDGET(tab->webview),
TRUE, TRUE);
/* Position 0 = sidebar gets no space (hidden by default). */
gtk_paned_set_position(GTK_PANED(tab->paned), 0);
gtk_box_pack_start(GTK_BOX(tab->page), tab->paned, TRUE, TRUE, 0);
/* Build the tab label. */
tab->tab_label = build_tab_label(tab);
@@ -2334,6 +2395,15 @@ int tab_manager_new_tab(const char *url) {
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* gtk_widget_show_all() recursively shows all children, including
* the sidebar container that tab_create() explicitly hid. Re-hide
* the sidebar so it stays hidden by default — it should only appear
* when the user toggles it via Ctrl+Shift+A, the menu item, or the
* ";" URL-bar shortcut. (Issue 2: sidebar visible on startup.) */
if (tab->sidebar_container) {
gtk_widget_hide(tab->sidebar_container);
}
/* Switch to the new tab (now that it's visible). */
gtk_notebook_set_current_page(GTK_NOTEBOOK(nb), page_num);
@@ -2518,3 +2588,83 @@ void tab_manager_reload(int index) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
/* ── Agent chat sidebar (split view) ─────────────────────────────── */
#define SIDEBAR_DEFAULT_WIDTH 280
#define AGENT_CHAT_URL_STR "sovereign://agents/chat"
/* Lazily create the sidebar webview for a tab. The webview shares the
* same WebKitWebContext as the main webview so the sovereign:// scheme
* works. It is packed into the sidebar container (the left pane of the
* GtkPaned). Called the first time the sidebar is shown for a tab. */
static void sidebar_create_webview(tab_info_t *tab) {
if (tab == NULL || tab->sidebar_webview != NULL) return;
if (g_ctx == NULL) return;
tab->sidebar_webview = WEBKIT_WEB_VIEW(
webkit_web_view_new_with_context(g_ctx));
/* Match the main webview's settings so JS and the sovereign://
* bridge work. */
WebKitSettings *st = webkit_web_view_get_settings(tab->sidebar_webview);
webkit_settings_set_enable_developer_extras(st, TRUE);
webkit_settings_set_enable_javascript(st, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(st, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(st, TRUE);
/* Inject window.nostr so the chat page's NIP-07 shim works. */
nostr_inject_setup(tab->sidebar_webview);
/* The sidebar webview should NOT create new windows/tabs — links
* in the chat page should navigate within the sidebar, not open
* new browser tabs. We let the default navigation happen inside
* the sidebar webview. */
gtk_widget_set_vexpand(GTK_WIDGET(tab->sidebar_webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->sidebar_webview), TRUE);
/* Pack into the sidebar container. */
gtk_box_pack_start(GTK_BOX(tab->sidebar_container),
GTK_WIDGET(tab->sidebar_webview), TRUE, TRUE, 0);
/* Load the chat page. */
webkit_web_view_load_uri(tab->sidebar_webview, AGENT_CHAT_URL_STR);
}
void tab_manager_toggle_sidebar(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->paned == NULL) return;
if (tab->sidebar_visible) {
/* Hide the sidebar. Set position to 0 so the sidebar gets no
* space and the divider disappears. Keep the webview alive. */
gtk_paned_set_position(GTK_PANED(tab->paned), 0);
gtk_widget_hide(tab->sidebar_container);
tab->sidebar_visible = FALSE;
} else {
/* Show the sidebar. Create the webview lazily on first show.
* Set position to 280 so the sidebar gets space and the
* divider appears (resizable). */
if (tab->sidebar_webview == NULL) {
sidebar_create_webview(tab);
}
gtk_paned_set_position(GTK_PANED(tab->paned),
SIDEBAR_DEFAULT_WIDTH);
gtk_widget_show_all(tab->sidebar_container);
tab->sidebar_visible = TRUE;
}
}
WebKitWebView *tab_manager_get_main_webview(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL) return NULL;
/* Always return the main webview, never the sidebar. */
return tab->webview;
}
gboolean tab_manager_sidebar_visible(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL) return FALSE;
return tab->sidebar_visible;
}

View File

@@ -30,6 +30,15 @@ typedef struct {
GtkWidget *favicon; /* favicon image in tab_label */
char current_url[TAB_URL_MAX];
char title[TAB_TITLE_MAX];
/* Agent chat sidebar (split view). The sidebar is a second webview
* showing sovereign://agents/chat, shown alongside the main webview
* in a GtkPaned. When sidebar_visible is FALSE, the sidebar
* container is hidden but the webview is kept alive so chat state
* persists across toggles. */
WebKitWebView *sidebar_webview; /* agent chat sidebar (NULL if not yet created) */
GtkWidget *sidebar_container; /* GtkScrolledWindow holding sidebar_webview */
GtkWidget *paned; /* GtkPaned: sidebar | main webview */
gboolean sidebar_visible; /* whether the sidebar is currently shown */
} tab_info_t;
/*
@@ -160,6 +169,27 @@ void tab_manager_set_avatar(const char *pubkey_hex);
*/
void tab_manager_toggle_inspector(void);
/*
* Toggle the agent chat sidebar for the active tab. When showing, if
* the sidebar webview has not been created yet, it is created (sharing
* the same WebKitWebContext so sovereign:// works) and loads
* sovereign://agents/chat. When hiding, the sidebar container is
* hidden but the webview is kept alive so chat state persists.
*/
void tab_manager_toggle_sidebar(void);
/*
* Returns the main webview (the web page) of the active tab, never the
* sidebar webview. This is used by agent tools so they always operate
* on the web page even when the sidebar is open and focused.
*/
WebKitWebView *tab_manager_get_main_webview(void);
/*
* Returns TRUE if the sidebar is currently visible for the active tab.
*/
gboolean tab_manager_sidebar_visible(void);
#ifdef __cplusplus
}
#endif

View File

@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.26"
#define SB_VERSION "v0.0.27"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 26
#define SB_VERSION_PATCH 27
#endif /* SOVEREIGN_BROWSER_VERSION_H */

442
www/agents/chat.css Normal file
View File

@@ -0,0 +1,442 @@
/* Shared sovereign:// page CSS (theme variables, base elements, buttons).
* Mirrors sovereign_page_css() in src/nostr_bridge.c so the chat page
* stays consistent with settings/profile/bookmarks pages. */
:root {
--font: monospace;
--primary: #000000;
--secondary: #ffffff;
--accent: #ff0000;
--muted: #dddddd;
--border: var(--muted);
--radius: 5px;
--bg: var(--secondary);
--fg: var(--primary);
--img-gray: 100%;
--img-gray-hover: 20%;
color-scheme: light;
}
html.dark {
--primary: #ffffff;
--secondary: #000000;
--accent: #ff0000;
--muted: #777777;
--border: var(--muted);
color-scheme: dark;
}
* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; }
html { transition: background-color 0.2s ease, color 0.2s ease; }
body { color: var(--fg); background: var(--bg); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; transition: 0.2s; }
a:hover { text-decoration: underline; }
input, select, textarea {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 10px; font-family: var(--font); font-size: 13px;
min-width: 200px;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent); outline: none;
}
.btn, .save-btn {
background: var(--bg); color: var(--primary);
border: 1px solid var(--primary); border-radius: var(--radius);
padding: 6px 16px; cursor: pointer; font-family: var(--font);
font-size: 13px; font-weight: bold; margin-left: 8px;
transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.btn:hover, .save-btn:hover { border-color: var(--accent); }
.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-del { border-color: var(--accent); color: var(--accent); }
.btn-del:hover { background: var(--accent); color: var(--secondary); }
/* ── Chat page layout (tabbed) ───────────────────────────────── */
#chat-root {
display: flex; flex-direction: column;
height: 100vh; padding: 8px;
}
/* Tab bar — horizontal row of tab buttons. */
#tab-bar {
display: flex; flex-direction: row; gap: 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0; margin-bottom: 8px;
}
.tab {
background: transparent; color: var(--fg);
border: none; border-bottom: 2px solid transparent;
padding: 8px 14px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; transition: border-color 0.15s, color 0.15s;
}
.tab:hover { color: var(--accent); }
.tab.active {
border-bottom-color: var(--accent);
color: var(--accent);
}
/* Tab content — only the active tab is visible. */
.tab-content {
display: none; flex: 1; min-height: 0;
flex-direction: column;
}
.tab-content.active { display: flex; }
/* Toolbar inside a tab (refresh + action buttons). */
.tab-toolbar {
display: flex; flex-direction: row; gap: 6px;
flex-shrink: 0; margin-bottom: 8px;
}
.tab-toolbar .btn { margin-left: 0; }
#new-chat-btn { margin-left: 0; }
#conv-list {
flex: 1; overflow-y: auto; display: flex;
flex-direction: column; gap: 4px;
}
/* Conversation list items — match ai.html conversation styling:
* title + timestamp on the left, delete button appears on hover. */
.conv-item {
display: flex; align-items: center; gap: 6px;
padding: 8px 10px; border-radius: var(--radius); cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover {
background: rgba(128,128,128,0.08);
}
.conv-active {
border-color: var(--accent);
background: rgba(255,0,0,0.06);
}
.conv-item-content {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
}
.conv-title {
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-size: 0.9em; color: var(--fg);
}
.conv-timestamp {
font-size: 0.72em; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.conv-del {
flex-shrink: 0; padding: 2px 8px; font-size: 0.75em;
opacity: 0; transition: opacity 0.15s;
}
.conv-item:hover .conv-del { opacity: 1; }
.conv-empty { color: var(--muted); font-size: 0.85em; padding: 8px; }
#create-skill-btn { margin-left: 0; }
/* Rename button — same hover-reveal behavior as the delete button. */
.conv-rename {
flex-shrink: 0; padding: 2px 8px; font-size: 0.75em;
opacity: 0; transition: opacity 0.15s; margin-left: 0;
border-color: var(--border); color: var(--fg);
}
.conv-rename:hover { border-color: var(--accent); color: var(--accent); }
.conv-item:hover .conv-rename { opacity: 1; }
/* "No chat selected" placeholder shown in the chat pane when no
* conversation is active. */
.no-chat-placeholder {
color: var(--muted); font-style: italic; font-size: 0.9em;
padding: 16px; text-align: center;
}
#skill-list {
flex: 1; overflow-y: auto; display: flex;
flex-direction: column; gap: 4px;
}
/* Skills list items — match ai.html skills styling:
* checkbox + skill name + tool badges, with delete on hover for
* user-authored skills. */
.skill-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border-radius: var(--radius); flex-wrap: wrap;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.skill-item:hover {
background: rgba(128,128,128,0.06);
border-color: var(--border);
}
.skill-checkbox { flex-shrink: 0; margin: 0; min-width: auto; }
.skill-name { font-size: 0.88em; flex-shrink: 0; color: var(--fg); }
.skill-badge {
font-size: 0.7em; padding: 1px 6px; border-radius: 3px;
white-space: nowrap;
}
.skill-badge-tools {
background: rgba(0,180,0,0.15); color: #2a2;
border: 1px solid rgba(0,180,0,0.4);
}
html.dark .skill-badge-tools {
background: rgba(0,180,0,0.2); color: #6c6;
border-color: rgba(0,180,0,0.5);
}
.skill-tool-enabled { border-left: 3px solid rgba(0,180,0,0.5); }
.skill-item .conv-del {
opacity: 0; padding: 1px 6px; font-size: 0.75em;
}
.skill-item:hover .conv-del { opacity: 1; }
/* ── Skill editor (inline, matching ai.html) ──────────────────── */
.skill-badge-unsaved {
background: rgba(255,165,0,0.15); color: #a60;
border: 1px solid rgba(255,165,0,0.4);
}
html.dark .skill-badge-unsaved {
background: rgba(255,165,0,0.2); color: #fc6;
border-color: rgba(255,165,0,0.5);
}
.skillStackedItem {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 8px; margin-bottom: 4px;
background: rgba(128,128,128,0.04);
}
.skillStackedItem > summary {
display: flex; align-items: center; gap: 6px;
cursor: pointer; flex-wrap: wrap; list-style: none;
user-select: none;
}
.skillStackedItem > summary::-webkit-details-marker { display: none; }
.skillStackedItem > summary .skill-checkbox {
flex-shrink: 0; margin: 0; min-width: auto;
}
.skillStackedItem > summary .skill-name {
font-size: 0.88em; flex-shrink: 0; color: var(--fg);
}
.aiSkillControlsRow {
display: flex; flex-direction: column; gap: 6px;
margin-top: 8px; padding-top: 6px;
border-top: 1px solid var(--border);
}
.aiSkillInlineField {
display: flex; flex-direction: column; gap: 2px;
}
.aiSkillInlineField label {
font-size: 0.75em; color: var(--muted); font-weight: bold;
}
.aiInput {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 4px 6px; font-family: var(--font); font-size: 0.85em;
min-width: 0; width: 100%; box-sizing: border-box;
}
.aiInput:focus { border-color: var(--accent); outline: none; }
.taSkillTemplateEditor {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 4px 6px; font-family: var(--font); font-size: 0.85em;
width: 100%; box-sizing: border-box; min-height: 80px;
resize: vertical;
}
.taSkillTemplateEditor:focus { border-color: var(--accent); outline: none; }
.aiSkillActionsRow {
display: flex; gap: 6px; margin-top: 6px;
}
.aiSkillActionsRow .btn { margin-left: 0; }
.aiSkillActionsRow .btn:disabled { opacity: 0.5; cursor: not-allowed; }
#create-skill-form {
display: flex; flex-direction: column; gap: 4px;
margin-top: 4px; padding: 8px;
border: 1px solid var(--border); border-radius: var(--radius);
background: rgba(128,128,128,0.05);
}
.skill-input {
width: 100%; box-sizing: border-box;
font-family: var(--font); font-size: 0.85em; padding: 4px 6px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--fg);
}
#skill-content { resize: vertical; min-height: 60px; }
/* The chat tab content fills the available height: messages scroll,
* input pinned to the bottom. */
#tab-chat { min-width: 0; }
#messages {
flex: 1; overflow-y: auto; border: 1px solid #444;
padding: 8px; margin-bottom: 8px; background: rgba(0,0,0,0.05);
}
/* ── Message bubbles (ported from ai.html messaging-ui.css) ────── */
.msg-bubble-row {
display: flex; width: 100%;
}
.msg-bubble-row.outgoing { justify-content: flex-end; }
.msg-bubble-row.incoming,
.msg-bubble-row.system { justify-content: flex-start; }
.msg-bubble {
max-width: 75%;
border: 1px solid var(--primary);
border-radius: 10px;
padding: 8px 10px;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 90%;
color: var(--fg);
background: var(--bg);
margin-bottom: 10px;
}
.msg-bubble.outgoing { border-color: var(--primary); }
.msg-bubble.incoming { border-color: var(--muted); }
.msg-bubble.system {
border-style: dashed;
border-color: var(--muted);
color: var(--muted);
font-size: 82%;
}
.msg-bubble-header {
display: flex; align-items: center;
justify-content: space-between; gap: 8px; margin-bottom: 6px;
}
.msg-bubble-header-main {
min-width: 0; flex: 1; display: flex;
align-items: center; gap: 8px;
}
.msg-bubble-sender-meta {
min-width: 0; display: flex; flex-direction: column; gap: 1px;
}
.msg-bubble-sender-name {
font-size: 78%; color: var(--fg); font-weight: bold;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.msg-bubble-menu-host { flex-shrink: 0; }
.msg-bubble-content { line-height: 1.35; }
.msg-bubble-content > *:first-child { margin-top: 0; }
.msg-bubble-content > *:last-child { margin-bottom: 0; }
.msg-bubble-content p { margin: 0 0 0.3em 0; }
.msg-bubble-content h1, .msg-bubble-content h2, .msg-bubble-content h3,
.msg-bubble-content h4, .msg-bubble-content h5, .msg-bubble-content h6 {
margin: 0.4em 0 0.2em 0; font-size: 1em;
}
.msg-bubble-content code {
background: rgba(128,128,128,0.18);
border-radius: 4px; padding: 1px 4px; font-size: 85%;
}
.msg-bubble-content pre {
background: rgba(0,0,0,0.15);
border-radius: 6px; padding: 6px 8px; margin: 4px 0;
overflow-x: auto;
}
.msg-bubble-content pre code {
background: transparent; padding: 0;
border-radius: 0; font-size: 90%;
}
.msg-bubble-content blockquote {
margin: 4px 0; padding: 2px 8px;
border-left: 3px solid var(--muted);
color: var(--muted);
}
.msg-bubble-content a {
color: var(--accent); text-decoration: underline; word-break: break-all;
}
.msg-bubble-content img {
display: block; max-width: 100%;
border-radius: 6px; margin: 4px 0;
}
.msg-bubble-content ul, .msg-bubble-content ol {
margin: 4px 0; padding-left: 20px;
}
.msg-bubble-content table {
width: 100%; border-collapse: collapse;
margin: 6px 0; font-size: 90%;
}
.msg-bubble-content th, .msg-bubble-content td {
border: 1px solid var(--border);
padding: 4px 8px; text-align: left; vertical-align: top;
}
.msg-bubble-content thead th { background: rgba(128,128,128,0.12); }
.msg-bubble-content tbody tr:nth-child(even) { background: rgba(128,128,128,0.06); }
.msg-bubble-content del, .msg-bubble-content s { opacity: 0.75; }
.msg-bubble-content strong { font-weight: bold; }
.msg-tcid { font-size: 0.8em; color: var(--muted); margin-top: 4px; }
/* ── Dot-menu ────────────────────────────────────────────────────
* The dot-menu styles now come from the shared sovereign://css/dot-menu.css
* (copied from the client). Only the host sizing is page-specific. */
.msg-bubble-menu-host .dot-menu-wrap { flex-shrink: 0; }
/* ── Tool calls, status message, input area ───────────────────── */
.tool-calls { margin-top: 4px; }
.tool-calls pre {
font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word;
}
/* Status message — a temporary system bubble shown in the chat window
* (replaces the old #status-bar). It is italic and muted to distinguish
* it from real agent responses. */
#status-msg {
margin-bottom: 8px;
}
#status-msg-bubble {
font-style: italic; color: var(--muted);
max-width: 90%; border-style: dashed;
}
/* The input area is a flex row: the composer (textarea + send button)
* on the left taking all available space, and the Stop button on the
* right (only visible while the agent is running). */
#input-area {
display: flex; align-items: flex-end; gap: 8px;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 8px; background: var(--bg);
}
#composer-host {
flex: 1; min-width: 0;
}
#stop-btn {
flex-shrink: 0; min-width: 80px; margin-left: 0;
}
/* The shared post-composer library wraps the host element: it inserts a
* .post-composer-wrapper around #composer-host and appends the send
* button as a SIBLING of the host (inside the wrapper). So the send
* button is NOT a descendant of #composer-host — it lives in
* #input-area > .post-composer-wrapper > [.post-composer-editor-shell >
* #composer-host, .post-composer-send-btn]. Target it via #input-area.
* Make the wrapper fill the row and ensure the send button has a
* visible border matching the page .btn style (post-composer.css uses
* --primary-color which is undefined here, so we override it). */
#input-area .post-composer-wrapper {
width: 100%; min-width: 0; max-width: none; margin-bottom: 0;
border: none; padding: 0; background: transparent;
display: flex; flex-direction: column; gap: 0;
}
#input-area .post-composer-send-btn {
margin-left: 0; min-width: 80px;
border: 1px solid var(--primary);
color: var(--primary);
}
#input-area .post-composer-send-btn:hover,
#input-area .post-composer-send-btn:focus {
border-color: var(--accent);
}
#input-area .post-composer-send-btn:disabled {
opacity: 0.5; cursor: not-allowed;
}
/* ── Responsive: narrow sidebar viewport (< 400px) ──────────────
* When the chat page is loaded in the agent sidebar (~350px wide),
* switch to a compact layout: smaller fonts, shorter tab labels,
* full-width messages, tighter padding. */
@media (max-width: 400px) {
#chat-root { padding: 4px; }
.tab {
padding: 6px 8px; font-size: 12px;
}
/* Shorten tab labels to single characters in narrow view. */
.tab[data-tab="chat"] { font-size: 0; }
.tab[data-tab="chat"]::before { content: "💬"; font-size: 14px; }
.tab[data-tab="conversations"] { font-size: 0; }
.tab[data-tab="conversations"]::before { content: "☰"; font-size: 14px; }
.tab[data-tab="skills"] { font-size: 0; }
.tab[data-tab="skills"]::before { content: "⚙"; font-size: 14px; }
#messages {
padding: 4px; font-size: 12px;
}
.msg-bubble {
max-width: 92%; font-size: 85%; padding: 6px 8px;
}
.msg-bubble.system { font-size: 78%; }
#input-area { padding: 4px; gap: 4px; }
.tab-toolbar { gap: 4px; }
.tab-toolbar .btn { padding: 4px 10px; font-size: 12px; }
.conv-item { padding: 6px 8px; }
.conv-title { font-size: 0.85em; }
.skill-name { font-size: 0.82em; }
.skill-input { font-size: 12px; }
}

63
www/agents/chat.html Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Chat</title>
<link rel="stylesheet" href="sovereign://css/dot-menu.css">
<link rel="stylesheet" href="sovereign://css/post-composer.css">
<link rel="stylesheet" href="sovereign://agents/chat.css">
</head>
<body>
<div id="chat-root">
<div id="tab-bar">
<button class="tab active" data-tab="chat" onclick="switchTab('chat')">Chat</button>
<button class="tab" data-tab="conversations" onclick="switchTab('conversations')">Conversations</button>
<button class="tab" data-tab="skills" onclick="switchTab('skills')">Skills</button>
</div>
<div id="tab-chat" class="tab-content active">
<div id="messages"></div>
<div id="no-chat-placeholder" class="no-chat-placeholder">
No conversations yet. Click &lsquo;+ New Chat&rsquo; to start.
</div>
<div id="status-msg" class="msg-bubble-row system" style="display:none">
<div class="msg-bubble system" id="status-msg-bubble"></div>
</div>
<div id="input-area">
<div id="composer-host"></div>
<button id="stop-btn" class="btn btn-del" style="display:none" onclick="cancelAgent()">Stop</button>
</div>
</div>
<div id="tab-conversations" class="tab-content">
<div class="tab-toolbar">
<button id="new-chat-btn" class="btn" onclick="newChat()">+ New Chat</button>
<button class="btn" onclick="fetchConvList()" title="Refresh">&#8635;</button>
</div>
<div id="conv-list"></div>
</div>
<div id="tab-skills" class="tab-content">
<div class="tab-toolbar">
<button id="create-skill-btn" class="btn" onclick="showCreateSkillForm()">+ Create Skill</button>
<button class="btn" onclick="fetchSkills()" title="Refresh">&#8635;</button>
</div>
<div id="skill-list"></div>
<div id="create-skill-form" style="display:none">
<input id="skill-name" placeholder="Skill name" class="skill-input">
<input id="skill-desc" placeholder="Description (optional)" class="skill-input">
<textarea id="skill-content" rows="4" placeholder="System prompt template..." class="skill-input"></textarea>
<input id="skill-tools" placeholder="Tools (comma-separated, optional)" class="skill-input">
<button class="btn" onclick="publishSkill()">Publish</button>
<button class="btn btn-del" onclick="hideCreateSkillForm()">Cancel</button>
</div>
</div>
</div>
<script src="sovereign://vendor/marked.min.js"></script>
<script src="sovereign://vendor/purify.min.js"></script>
<script src="sovereign://js/dot-menu.js"></script>
<script src="sovereign://js/post-composer.js"></script>
<script src="sovereign://agents/chat.js"></script>
</body>
</html>

998
www/agents/chat.js Normal file
View File

@@ -0,0 +1,998 @@
/* 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();

3
www/agents/config.css Normal file
View File

@@ -0,0 +1,3 @@
/* Agent config page — sovereign://agents
* Imports the shared sovereign:// base theme. */
@import url("sovereign://sovereign-base.css");

107
www/agents/config.html Normal file
View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent</title>
<link rel="stylesheet" href="sovereign://agents/config.css">
</head>
<body>
<h1>Agent</h1>
<p class="note">
Configure the LLM provider for the embedded agent. Providers are
shared across all apps (sovereign_browser, client, didactyl) via the
Nostr <code>d:user-settings</code> event. The model, system prompt,
and max iterations are per-app. Type a semicolon then a message in
the URL bar to talk to the agent.
</p>
<h2>Providers (shared)</h2>
<div class="field">
<div><div class="setting-name">Active provider</div></div>
<div><select id="agent.provider" onchange="onProviderChange()"></select></div>
</div>
<div class="field">
<div><div class="setting-name">Provider name</div></div>
<div><input type="text" id="provider_name" size="30">
<button class="save-btn" onclick="saveProviderName()">Rename</button></div>
</div>
<div class="field">
<div><div class="setting-name">Base URL</div></div>
<div><input type="text" id="agent.llm_base_url" size="50">
<button class="save-btn" onclick="saveAndRefresh('agent.llm_base_url')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">API Key</div></div>
<div><input type="text" id="agent.llm_api_key" size="50">
<label style="margin-left:4px;font-size:12px">
<input type="checkbox" id="api-key-hide"
onchange="toggleKeyHide()">Hide</label>
<button class="save-btn" onclick="saveAndRefresh('agent.llm_api_key')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Manage providers</div></div>
<div><input type="text" id="new_provider_name" size="20"
placeholder="new provider name">
<button class="save-btn" onclick="addProvider()">Add</button>
<button class="btn btn-del" onclick="removeProvider()">Remove Active</button></div>
</div>
<h2>Agent Config (per-app)</h2>
<div class="field">
<div><div class="setting-name">Model</div></div>
<div><select id="agent.llm_model" onchange="onModelChange()"></select>
<input type="text" id="agent.llm_model_text" size="30"
style="display:none;margin-left:4px"
oninput="onModelTextInput()">
<button id="refresh-models-btn" class="save-btn"
onclick="refreshModels()">Refresh Models</button></div>
</div>
<div class="field">
<div><div class="setting-name">Max iterations</div></div>
<div><input type="number" id="agent.max_iterations" min="1" max="10000">
<button class="save-btn" onclick="save('agent.max_iterations')">Save</button></div>
</div>
<h2>Sovereign Browser Skill</h2>
<p class="note">
The system prompt is treated as the default skill for sovereign_browser.
Edit it here, then click "Save as Skill" to publish it to Nostr as a
kind 31123 event (shareable with client, didactyl, and other apps).
</p>
<div class="field">
<div><div class="setting-name">Name</div></div>
<div><input type="text" id="agent.skill_name" size="50">
<button class="save-btn" onclick="save('agent.skill_name')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Description</div></div>
<div><input type="text" id="agent.skill_description" size="70">
<button class="save-btn" onclick="save('agent.skill_description')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Template (system prompt)</div></div>
<div><textarea id="agent.skill_template" rows="8" cols="70"></textarea>
<button class="save-btn" onclick="save('agent.skill_template')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Requires tools (comma-separated)</div></div>
<div><input type="text" id="agent.skill_requires_tools" size="50">
<button class="save-btn" onclick="save('agent.skill_requires_tools')">Save</button>
<button class="save-btn" onclick="saveAsSkill()">Save as Skill</button></div>
</div>
<p><a href="sovereign://agents/chat">Open Agent Chat</a></p>
<div id="status" class="status"></div>
<script src="sovereign://agents/config.js"></script>
</body>
</html>

289
www/agents/config.js Normal file
View File

@@ -0,0 +1,289 @@
/* Agent config page — sovereign://agents
*
* Fetches the current agent config from sovereign://agents/config (JSON)
* on page load and populates the form. Saves use sovereign://agents/set.
* Model refresh uses sovereign://agents/models.
*/
var savedModel = '';
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 { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function showStatus(cls, msg) {
var s = document.getElementById('status');
s.className = 'status ' + cls + ' show';
s.textContent = msg;
}
function toggleKeyHide() {
var cb = document.getElementById('api-key-hide');
var ak = document.getElementById('agent.llm_api_key');
ak.type = cb.checked ? 'password' : 'text';
}
function applyConfig(d) {
/* Provider dropdown. */
var sel = document.getElementById('agent.provider');
sel.innerHTML = '';
(d.providers || []).forEach(function(name, i) {
var opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
if (i === d.active_provider) opt.selected = true;
sel.appendChild(opt);
});
/* Active provider fields. */
document.getElementById('provider_name').value = d.provider_name || '';
document.getElementById('agent.llm_base_url').value = d.base_url || '';
document.getElementById('agent.llm_api_key').value = d.api_key || '';
/* Model dropdown. */
savedModel = d.model || '';
populateModels(d.models || []);
/* Max iterations + Sovereign Browser Skill fields. */
document.getElementById('agent.max_iterations').value =
d.max_iterations != null ? d.max_iterations : 100;
document.getElementById('agent.skill_name').value =
d.skill_name || 'Sovereign Browser Default';
document.getElementById('agent.skill_description').value =
d.skill_description || 'Default agent skill for sovereign_browser';
document.getElementById('agent.skill_template').value =
d.skill_template || d.system_prompt || '';
document.getElementById('agent.skill_requires_tools').value =
d.skill_requires_tools || 'browser, fs, shell';
updateRefreshBtn();
}
function save(key) {
var el = document.getElementById(key);
var val = el ? el.value : '';
return sovereignGet('sovereign://agents/set?key=' + encodeURIComponent(key) +
'&value=' + encodeURIComponent(val))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else showStatus('ok', d.key + ' saved');
return d;
})
.catch(function(e) {
showStatus('err', 'Error: ' + e.message);
throw e;
});
}
function saveAndRefresh(key) {
save(key).then(function() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
if (bu && bu.value && bu.value.trim() && ak && ak.value && ak.value.trim()) {
refreshModels();
}
}).catch(function() {});
}
function updateRefreshBtn() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
var btn = document.getElementById('refresh-models-btn');
var enabled = bu && bu.value && bu.value.trim() &&
ak && ak.value && ak.value.trim();
btn.disabled = !enabled;
}
function onProviderChange() {
save('agent.provider').then(function() { window.location.reload(); });
}
function saveProviderName() {
var el = document.getElementById('provider_name');
if (el && el.value && el.value.trim()) {
save('agent.provider_name').then(function() { window.location.reload(); });
}
}
function addProvider() {
var el = document.getElementById('new_provider_name');
if (el && el.value && el.value.trim()) {
sovereignGet('sovereign://agents/set?key=agent.provider_add&value=' +
encodeURIComponent(el.value.trim()))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else { showStatus('ok', 'Provider added'); window.location.reload(); }
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
}
function removeProvider() {
var sel = document.getElementById('agent.provider');
if (sel && sel.value) {
if (!confirm('Remove provider "' + sel.value + '"?')) return;
sovereignGet('sovereign://agents/set?key=agent.provider_remove&value=' +
encodeURIComponent(sel.value))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else { showStatus('ok', 'Provider removed'); window.location.reload(); }
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
}
function onModelChange() {
var sel = document.getElementById('agent.llm_model');
var txt = document.getElementById('agent.llm_model_text');
if (sel.value === '__custom__') {
txt.style.display = '';
txt.value = savedModel;
txt.focus();
} else {
txt.style.display = 'none';
savedModel = sel.value;
save('agent.llm_model');
}
}
function onModelTextInput() {
var txt = document.getElementById('agent.llm_model_text');
savedModel = txt.value;
}
function saveCustomModel() {
var txt = document.getElementById('agent.llm_model_text');
if (txt && txt.value && txt.value.trim()) {
savedModel = txt.value.trim();
save('agent.llm_model');
}
}
function populateModels(models) {
var sel = document.getElementById('agent.llm_model');
var txt = document.getElementById('agent.llm_model_text');
sel.innerHTML = '';
models = models.slice().sort(function(a, b) {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
var found = false;
for (var i = 0; i < models.length; i++) {
var opt = document.createElement('option');
opt.value = models[i];
opt.textContent = models[i];
if (models[i] === savedModel) { opt.selected = true; found = true; }
sel.appendChild(opt);
}
var copt = document.createElement('option');
copt.value = '__custom__';
copt.textContent = '(custom...)';
if (!found) { copt.selected = true; sel.value = '__custom__'; }
sel.appendChild(copt);
if (!found) {
txt.style.display = '';
txt.value = savedModel;
} else {
txt.style.display = 'none';
}
}
function refreshModels() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
if (!bu || !bu.value || !bu.value.trim()) return;
var btn = document.getElementById('refresh-models-btn');
btn.disabled = true;
btn.textContent = 'Loading...';
var url = 'sovereign://agents/models?base_url=' +
encodeURIComponent(bu.value.trim()) +
'&api_key=' + encodeURIComponent(ak ? ak.value : '');
sovereignGet(url)
.then(function(d) {
if (d && d.error) {
showStatus('err', 'Models: ' + d.error);
} else if (d && d.length) {
populateModels(d);
showStatus('ok', 'Loaded ' + d.length + ' models');
} else {
showStatus('err', 'No models returned');
}
})
.catch(function(e) { showStatus('err', 'Models error: ' + e.message); })
.then(function() {
btn.textContent = 'Refresh Models';
updateRefreshBtn();
});
}
/* ── Save as Skill (publish to Nostr as kind 31123) ──────────── */
function saveAsSkill() {
var name = document.getElementById('agent.skill_name').value.trim();
var desc = document.getElementById('agent.skill_description').value.trim();
var content = document.getElementById('agent.skill_template').value.trim();
var toolsRaw = document.getElementById('agent.skill_requires_tools').value.trim();
if (!name || !content) {
showStatus('err', '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; });
}
/* Save the local fields first, then publish to Nostr. */
save('agent.skill_name').then(function() {
return save('agent.skill_description');
}).then(function() {
return save('agent.skill_template');
}).then(function() {
return save('agent.skill_requires_tools');
}).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) {
showStatus('err', 'Publish failed: ' + d.message);
} else {
showStatus('ok', 'Skill published to Nostr (d: ' + d.d + ')');
}
}).catch(function(e) {
showStatus('err', 'Error: ' + e.message);
});
}
/* ── Init ───────────────────────────────────────────────────────── */
sovereignGet('sovereign://agents/config?_=' + Date.now())
.then(function(d) { applyConfig(d); })
.catch(function(e) { showStatus('err', 'Failed to load config: ' + e.message); });
document.addEventListener('DOMContentLoaded', function() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
var txt = document.getElementById('agent.llm_model_text');
if (bu) bu.addEventListener('input', updateRefreshBtn);
if (ak) ak.addEventListener('input', updateRefreshBtn);
if (txt) txt.addEventListener('change', saveCustomModel);
});
/* After config loads, auto-refresh models if base URL + API key are set. */
sovereignGet('sovereign://agents/config?_=' + Date.now())
.then(function(d) {
if (d.base_url && d.base_url.trim() && d.api_key && d.api_key.trim()) {
refreshModels();
}
})
.catch(function() {});

8
www/bookmarks.css Normal file
View File

@@ -0,0 +1,8 @@
/* Bookmarks page — sovereign://bookmarks
* Imports the shared sovereign:// base theme, then adds bookmarks-specific
* layout. The base CSS lives in www/sovereign-base.css and is served at
* sovereign://sovereign-base.css. */
@import url("sovereign://sovereign-base.css");
/* Bookmarks-specific tweaks (most layout is already in base .bm/.form/.dir-header). */
#bm-dir { min-width: 160px; }

36
www/bookmarks.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bookmarks</title>
<link rel="stylesheet" href="sovereign://bookmarks.css">
</head>
<body>
<h1>Bookmarks</h1>
<p id="readonly-note" class="note" style="display:none">
Read-only mode — sign in to add, edit, or delete bookmarks.
Showing cached bookmarks from the local database.
</p>
<div id="add-form" style="display:none">
<h2>Add Bookmark</h2>
<div class="form">
<input type="text" id="bm-url" placeholder="URL" size="40">
<input type="text" id="bm-title" placeholder="Title" size="25">
<select id="bm-dir"></select>
<button class="btn" onclick="addBookmark()">Add</button>
</div>
<div class="form">
<input type="text" id="new-dir" placeholder="New directory name" size="25">
<button class="btn" onclick="createDir()">Create Directory</button>
</div>
</div>
<h2>Bookmarks</h2>
<div id="bm-list"></div>
<script src="sovereign://bookmarks.js"></script>
</body>
</html>

115
www/bookmarks.js Normal file
View File

@@ -0,0 +1,115 @@
/* Bookmarks page — sovereign://bookmarks
*
* Fetches the bookmark directory list from sovereign://bookmarks/list
* (JSON) and renders it. Add/delete/create-dir use the existing
* sovereign://bookmarks/{add,delete,createdir,deletedir} endpoints.
*/
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 { resolve(JSON.parse(x.responseText)); }
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 == null ? '' : String(s);
return d.innerHTML;
}
function renderBookmarks(data) {
var haveSigner = !!data.have_signer;
document.getElementById('readonly-note').style.display =
haveSigner ? 'none' : '';
document.getElementById('add-form').style.display =
haveSigner ? '' : 'none';
/* Populate the directory <select> for the add form. */
var sel = document.getElementById('bm-dir');
sel.innerHTML = '';
(data.dirs || []).forEach(function(d) {
var opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.name;
sel.appendChild(opt);
});
var c = document.getElementById('bm-list');
var dirs = data.dirs || [];
if (dirs.length === 0) {
c.innerHTML =
'<p class="empty">No bookmarks yet. Click the bookmark button ' +
'in the toolbar to save a page.</p>';
return;
}
var html = '';
dirs.forEach(function(dir) {
html += '<div class="dir-header"><h3>' + esc(dir.name) +
' (' + dir.count + ')</h3>';
if (haveSigner && dir.name !== 'General') {
var enc = encodeURIComponent(dir.name);
html += ' <a class="btn btn-del" href="sovereign://bookmarks/deletedir?dir=' +
enc + '" onclick="return confirm(\'Delete directory "' +
esc(dir.name) + '"? Bookmarks will be moved to General.\')">Delete Dir</a>';
}
html += '</div>';
if (dir.count === 0) {
html += '<p class="empty">(empty)</p>';
} else {
(dir.bookmarks || []).forEach(function(bm) {
var urlEnc = encodeURIComponent(bm.url);
var dirEnc = encodeURIComponent(dir.name);
var title = bm.title && bm.title.length ? bm.title : '(untitled)';
html += '<div class="bm"><div>';
html += '<div class="bm-title">' + esc(title) + '</div>';
html += '<div><a class="bm-url" href="' + esc(bm.url) + '">' +
esc(bm.url) + '</a></div>';
html += '<div class="bm-meta">Added: ' + esc(bm.added) + '</div></div>';
html += '<div class="actions">';
html += '<a class="btn" href="' + esc(bm.url) + '">Open</a>';
if (haveSigner) {
html += ' <a class="btn btn-del" href="sovereign://bookmarks/delete?dir=' +
dirEnc + '&url=' + urlEnc +
'" onclick="return confirm(\'Delete this bookmark?\')">Delete</a>';
}
html += '</div></div>';
});
}
});
c.innerHTML = html;
}
function fetchBookmarks() {
sovereignGet('sovereign://bookmarks/list?_=' + Date.now())
.then(function(data) { renderBookmarks(data); })
.catch(function(e) {
document.getElementById('bm-list').innerHTML =
'<p class="empty">Failed to load bookmarks: ' + esc(e.message) + '</p>';
});
}
function addBookmark() {
var url = encodeURIComponent(document.getElementById('bm-url').value);
var title = encodeURIComponent(document.getElementById('bm-title').value);
var dir = encodeURIComponent(document.getElementById('bm-dir').value);
if (!url) { alert('URL is required'); return; }
location.href = 'sovereign://bookmarks/add?dir=' + dir + '&url=' + url +
'&title=' + title;
}
function createDir() {
var name = encodeURIComponent(document.getElementById('new-dir').value);
if (!name) { alert('Directory name is required'); return; }
location.href = 'sovereign://bookmarks/createdir?name=' + name;
}
fetchBookmarks();

142
www/css/dot-menu.css Normal file
View File

@@ -0,0 +1,142 @@
.dot-menu-wrap {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.dot-menu-trigger {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: var(--avatar-radius);
border: var(--border);
background: var(--secondary-color);
color: var(--muted-color);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-family);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 0;
}
.dot-menu-trigger:hover {
color: var(--accent-color);
border-color: var(--accent-color);
}
.dot-menu-trigger:focus-visible {
outline: var(--border-width2) solid var(--accent-color);
outline-offset: 1px;
}
.dot-menu-panel {
position: absolute;
top: calc(100% + 5px);
right: 0;
z-index: 200;
display: none;
flex-direction: column;
gap: 2px;
min-width: 180px;
max-width: min(320px, calc(100vw - 16px));
padding: 5px;
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
}
.dot-menu-panel[data-position='left'] {
left: 0;
right: auto;
}
.dot-menu-panel[data-position='right'] {
right: 0;
left: auto;
}
.dot-menu-panel.is-open {
display: flex;
}
.dot-menu-item,
.dot-menu-submenu-label {
width: 100%;
display: block;
border: none;
border-radius: var(--border-radius);
background: transparent;
color: var(--primary-color);
text-align: left;
font-family: var(--font-family);
font-size: 14px;
line-height: 1.3;
cursor: pointer;
padding: 2px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dot-menu-item:hover,
.dot-menu-submenu-label:hover {
background: var(--background-color);
color: var(--accent-color);
}
.dot-menu-submenu {
position: relative;
margin: 0;
padding: 0;
}
.dot-menu-submenu-label {
list-style: none;
display: block;
}
.dot-menu-submenu-label::-webkit-details-marker {
display: none;
}
.dot-menu-submenu-panel {
position: fixed;
z-index: 260;
display: none;
flex-direction: column;
align-items: stretch;
gap: 4px;
min-width: 180px;
max-width: min(320px, calc(100vw - 16px));
max-height: min(60vh, 420px);
overflow-y: auto;
padding: 5px;
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
}
.dot-menu-submenu[open] > .dot-menu-submenu-panel {
display: flex;
}
.dot-menu-submenu-panel > .dot-menu-item,
.dot-menu-submenu-panel > .dot-menu-submenu {
flex: 0 0 auto;
}
.dot-menu-submenu-panel > .dot-menu-item,
.dot-menu-submenu-panel > .dot-menu-submenu > .dot-menu-submenu-label {
min-height: 22.17px;
box-sizing: border-box;
}
.dot-menu-divider {
height: var(--border-width);
margin: 2px 4px;
background: var(--muted-color);
}

321
www/css/post-composer.css Normal file
View File

@@ -0,0 +1,321 @@
.post-composer-wrapper {
width: 60%;
min-width: 300px;
max-width: 800px;
margin-bottom: 20px;
}
.post-composer-wrapper--inline {
width: 100%;
min-width: 0;
max-width: none;
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: 0;
}
/* Popup mode — wrapper fills its container, body scrolls, footer sticks */
.post-composer-wrapper--popup {
width: 100%;
min-width: 0;
max-width: none;
margin-bottom: 0;
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
}
.post-composer-popup-body {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.post-composer-popup-footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
padding-top: 10px;
border-top: 1px solid var(--muted-color, #ccc);
margin-top: 8px;
}
.post-composer-popup-footer .post-composer-send-btn {
display: block !important;
width: 100%;
margin-top: 0;
}
.post-composer-inline-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 4px 0;
margin: 0;
}
.post-composer-wrapper--inline .post-composer-editor-shell {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.post-composer-wrapper--inline .post-composer-editor-shell [contenteditable='true'] {
width: 100%;
flex: 1;
min-height: 40px;
height: auto;
overflow-y: visible;
display: block;
padding: 8px 10px;
line-height: 1.3;
border: 1px solid var(--primary-color);
border-radius: 8px;
background: var(--secondary-color);
box-sizing: border-box;
}
.post-composer-wrapper--inline .post-composer-send-btn {
width: auto;
min-width: 78px;
height: 40px;
padding: 0 16px;
line-height: 1;
margin-top: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.post-composer-editor-shell {
position: relative;
}
.inlinePostComposerInput {
min-height: 0;
padding: 0;
line-height: 1.4;
}
.post-composer-wrapper [contenteditable='true'] {
width: 100%;
margin-bottom: 0;
border: 1px solid var(--primary-color);
border-radius: 8px;
background: var(--secondary-color);
}
.post-composer-upload-icon {
position: absolute;
right: 10px;
bottom: 10px;
width: 32px;
height: 32px;
border: 1px solid var(--muted-color);
border-radius: 50%;
background: transparent;
color: var(--muted-color);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
padding: 0;
line-height: 0;
}
.post-composer-wrapper--inline .post-composer-upload-icon {
top: 50%;
bottom: auto;
transform: translateY(-50%);
}
.post-composer-upload-icon:hover,
.post-composer-upload-icon:focus,
.post-composer-upload-icon:focus-visible {
color: var(--accent-color);
border-color: var(--accent-color);
background: transparent;
outline: none;
}
.post-composer-upload-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2;
display: block;
}
.post-composer-upload-icon.is-image-icon svg {
transform: translateY(0.5px);
}
.post-composer-drag-overlay {
position: absolute;
inset: 0;
border: 2px dashed var(--accent-color);
border-radius: 10px;
background: color-mix(in srgb, var(--secondary-color) 78%, transparent);
pointer-events: none;
display: none;
align-items: center;
justify-content: center;
text-align: center;
color: var(--accent-color);
font-size: 90%;
z-index: 3;
}
.post-composer-editor-shell.is-dragover .post-composer-drag-overlay {
display: flex;
}
.post-composer-uploading {
position: absolute;
right: 52px;
bottom: 15px;
font-size: 75%;
color: var(--muted-color);
z-index: 2;
}
.post-composer-preview {
margin-top: 10px;
border: 1px solid var(--muted-color);
border-radius: 10px;
padding: 10px 12px;
background: var(--secondary-color);
display: none;
}
.post-composer-wrapper--inline .post-composer-preview {
margin-top: 0;
}
.post-composer-preview.is-visible {
display: block;
}
.post-composer-preview-label {
font-size: 75%;
color: var(--muted-color);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.post-composer-preview-content {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.post-composer-preview-content img,
.post-composer-preview-content video,
.post-composer-preview-content canvas,
.post-composer-preview-content iframe {
max-width: 100%;
height: auto;
}
.post-composer-send-btn {
width: 100%;
padding: 10px 20px;
border: 2px solid var(--primary-color);
border-radius: 10px;
font-family: var(--font-family);
font-size: 100%;
color: var(--muted-color);
background-color: var(--secondary-color);
cursor: pointer;
text-align: center;
margin-top: 10px;
transition: color 0.2s, border-color 0.2s;
display: none;
}
.post-composer-send-btn.is-visible,
.post-composer-send-btn.is-persistent-visible {
display: block;
}
.post-composer-send-btn:hover,
.post-composer-send-btn:focus,
.post-composer-send-btn:focus-visible {
color: var(--primary-color);
border-color: var(--primary-color);
outline: none;
}
.post-composer-mention-dropdown {
position: fixed;
min-width: 240px;
max-width: 320px;
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--muted-color);
border-radius: 8px;
background: var(--secondary-color);
z-index: 50;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.2);
display: none;
}
.post-composer-mention-dropdown.is-visible {
display: block;
}
.post-composer-mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid color-mix(in srgb, var(--muted-color) 35%, transparent);
}
.post-composer-mention-item:last-child {
border-bottom: none;
}
.post-composer-mention-item:hover,
.post-composer-mention-item.is-active {
background: color-mix(in srgb, var(--secondary-color) 70%, var(--accent-color));
}
.post-composer-mention-avatar {
width: 24px;
height: 24px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.post-composer-mention-meta {
min-width: 0;
}
.post-composer-mention-name {
color: var(--primary-color);
font-size: 85%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.post-composer-mention-handle {
color: var(--muted-color);
font-size: 75%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

232
www/js/dot-menu.js Normal file
View File

@@ -0,0 +1,232 @@
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeItems(items) {
return Array.isArray(items) ? items.filter(Boolean) : [];
}
function isSubmenuItem(item) {
return item && typeof item === 'object' && Array.isArray(item.submenu);
}
function positionSubmenuPanel(detailsEl) {
if (!detailsEl) return;
const summaryEl = detailsEl.querySelector('.dot-menu-submenu-label');
const panelEl = detailsEl.querySelector('.dot-menu-submenu-panel');
if (!summaryEl || !panelEl) return;
const gap = 6;
const pad = 8;
const summaryRect = summaryEl.getBoundingClientRect();
const panelRect = panelEl.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const panelWidth = panelRect.width || panelEl.offsetWidth || 180;
const panelHeight = panelRect.height || panelEl.offsetHeight || 40;
let left = summaryRect.right + gap;
if (left + panelWidth > viewportWidth - pad) {
left = summaryRect.left - panelWidth - gap;
}
left = clamp(left, pad, Math.max(pad, viewportWidth - panelWidth - pad));
let top = summaryRect.top;
if (top + panelHeight > viewportHeight - pad) {
top = viewportHeight - panelHeight - pad;
}
top = clamp(top, pad, Math.max(pad, viewportHeight - panelHeight - pad));
panelEl.style.left = `${Math.round(left)}px`;
panelEl.style.top = `${Math.round(top)}px`;
}
function closeSiblingSubmenus(parentEl, exceptEl = null) {
if (!parentEl) return;
const siblings = parentEl.querySelectorAll(':scope > .dot-menu-submenu[open]');
siblings.forEach((detailsEl) => {
if (exceptEl && detailsEl === exceptEl) return;
detailsEl.open = false;
});
}
function renderItems(containerEl, items, closeMenu) {
containerEl.innerHTML = '';
const normalized = normalizeItems(items);
normalized.forEach((item) => {
if (!item || typeof item !== 'object') return;
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'dot-menu-divider';
divider.setAttribute('role', 'separator');
containerEl.appendChild(divider);
return;
}
if (isSubmenuItem(item)) {
const detailsEl = document.createElement('details');
detailsEl.className = 'dot-menu-submenu';
const summaryEl = document.createElement('summary');
summaryEl.className = 'dot-menu-submenu-label';
summaryEl.textContent = String(item.label || 'More');
const submenuPanel = document.createElement('div');
submenuPanel.className = 'dot-menu-submenu-panel';
renderItems(submenuPanel, item.submenu, closeMenu);
detailsEl.appendChild(summaryEl);
detailsEl.appendChild(submenuPanel);
containerEl.appendChild(detailsEl);
detailsEl.addEventListener('toggle', () => {
if (!detailsEl.open) return;
closeSiblingSubmenus(containerEl, detailsEl);
requestAnimationFrame(() => positionSubmenuPanel(detailsEl));
});
summaryEl.addEventListener('click', (event) => {
event.stopPropagation();
});
submenuPanel.addEventListener('click', (event) => {
event.stopPropagation();
});
return;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'dot-menu-item';
button.textContent = String(item.label || 'Untitled');
button.addEventListener('click', (event) => {
event.stopPropagation();
try {
if (typeof item.onClick === 'function') {
item.onClick();
}
} finally {
closeMenu();
}
});
containerEl.appendChild(button);
});
}
window.mountDotMenu = function mountDotMenu(hostEl, config = {}) {
if (!(hostEl instanceof Element)) {
throw new Error('mountDotMenu(hostEl, config) requires a valid host element.');
}
let currentItems = normalizeItems(config.items);
const triggerLabel = typeof config.triggerLabel === 'string' ? config.triggerLabel : '⋯';
const ariaLabel = typeof config.ariaLabel === 'string' && config.ariaLabel.trim()
? config.ariaLabel.trim()
: 'Menu options';
const position = config.position === 'left' ? 'left' : 'right';
const wrap = document.createElement('div');
wrap.className = 'dot-menu-wrap';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'dot-menu-trigger';
trigger.setAttribute('aria-label', ariaLabel);
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.textContent = triggerLabel;
const panel = document.createElement('div');
panel.className = 'dot-menu-panel';
panel.dataset.position = position;
panel.setAttribute('role', 'menu');
wrap.appendChild(trigger);
wrap.appendChild(panel);
hostEl.innerHTML = '';
hostEl.appendChild(wrap);
const close = () => {
panel.classList.remove('is-open');
wrap.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
detailsEl.open = false;
});
};
const open = () => {
panel.classList.add('is-open');
wrap.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
};
const toggle = () => {
if (panel.classList.contains('is-open')) {
close();
} else {
open();
}
};
const onTriggerClick = (event) => {
event.stopPropagation();
toggle();
};
const onDocumentClick = (event) => {
if (!wrap.contains(event.target)) {
close();
}
};
const onDocumentKeyDown = (event) => {
if (event.key === 'Escape') {
close();
}
};
const onViewportChange = () => {
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
positionSubmenuPanel(detailsEl);
});
};
trigger.addEventListener('click', onTriggerClick);
panel.addEventListener('click', (event) => event.stopPropagation());
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeyDown);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
renderItems(panel, currentItems, close);
const updateItems = (newItems) => {
currentItems = normalizeItems(newItems);
renderItems(panel, currentItems, close);
};
const destroy = () => {
close();
trigger.removeEventListener('click', onTriggerClick);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeyDown);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
if (wrap.parentNode) {
wrap.parentNode.removeChild(wrap);
}
};
return { open, close, destroy, updateItems };
}

232
www/js/dot-menu.mjs Normal file
View File

@@ -0,0 +1,232 @@
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeItems(items) {
return Array.isArray(items) ? items.filter(Boolean) : [];
}
function isSubmenuItem(item) {
return item && typeof item === 'object' && Array.isArray(item.submenu);
}
function positionSubmenuPanel(detailsEl) {
if (!detailsEl) return;
const summaryEl = detailsEl.querySelector('.dot-menu-submenu-label');
const panelEl = detailsEl.querySelector('.dot-menu-submenu-panel');
if (!summaryEl || !panelEl) return;
const gap = 6;
const pad = 8;
const summaryRect = summaryEl.getBoundingClientRect();
const panelRect = panelEl.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const panelWidth = panelRect.width || panelEl.offsetWidth || 180;
const panelHeight = panelRect.height || panelEl.offsetHeight || 40;
let left = summaryRect.right + gap;
if (left + panelWidth > viewportWidth - pad) {
left = summaryRect.left - panelWidth - gap;
}
left = clamp(left, pad, Math.max(pad, viewportWidth - panelWidth - pad));
let top = summaryRect.top;
if (top + panelHeight > viewportHeight - pad) {
top = viewportHeight - panelHeight - pad;
}
top = clamp(top, pad, Math.max(pad, viewportHeight - panelHeight - pad));
panelEl.style.left = `${Math.round(left)}px`;
panelEl.style.top = `${Math.round(top)}px`;
}
function closeSiblingSubmenus(parentEl, exceptEl = null) {
if (!parentEl) return;
const siblings = parentEl.querySelectorAll(':scope > .dot-menu-submenu[open]');
siblings.forEach((detailsEl) => {
if (exceptEl && detailsEl === exceptEl) return;
detailsEl.open = false;
});
}
function renderItems(containerEl, items, closeMenu) {
containerEl.innerHTML = '';
const normalized = normalizeItems(items);
normalized.forEach((item) => {
if (!item || typeof item !== 'object') return;
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'dot-menu-divider';
divider.setAttribute('role', 'separator');
containerEl.appendChild(divider);
return;
}
if (isSubmenuItem(item)) {
const detailsEl = document.createElement('details');
detailsEl.className = 'dot-menu-submenu';
const summaryEl = document.createElement('summary');
summaryEl.className = 'dot-menu-submenu-label';
summaryEl.textContent = String(item.label || 'More');
const submenuPanel = document.createElement('div');
submenuPanel.className = 'dot-menu-submenu-panel';
renderItems(submenuPanel, item.submenu, closeMenu);
detailsEl.appendChild(summaryEl);
detailsEl.appendChild(submenuPanel);
containerEl.appendChild(detailsEl);
detailsEl.addEventListener('toggle', () => {
if (!detailsEl.open) return;
closeSiblingSubmenus(containerEl, detailsEl);
requestAnimationFrame(() => positionSubmenuPanel(detailsEl));
});
summaryEl.addEventListener('click', (event) => {
event.stopPropagation();
});
submenuPanel.addEventListener('click', (event) => {
event.stopPropagation();
});
return;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'dot-menu-item';
button.textContent = String(item.label || 'Untitled');
button.addEventListener('click', (event) => {
event.stopPropagation();
try {
if (typeof item.onClick === 'function') {
item.onClick();
}
} finally {
closeMenu();
}
});
containerEl.appendChild(button);
});
}
export function mountDotMenu(hostEl, config = {}) {
if (!(hostEl instanceof Element)) {
throw new Error('mountDotMenu(hostEl, config) requires a valid host element.');
}
let currentItems = normalizeItems(config.items);
const triggerLabel = typeof config.triggerLabel === 'string' ? config.triggerLabel : '⋯';
const ariaLabel = typeof config.ariaLabel === 'string' && config.ariaLabel.trim()
? config.ariaLabel.trim()
: 'Menu options';
const position = config.position === 'left' ? 'left' : 'right';
const wrap = document.createElement('div');
wrap.className = 'dot-menu-wrap';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'dot-menu-trigger';
trigger.setAttribute('aria-label', ariaLabel);
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.textContent = triggerLabel;
const panel = document.createElement('div');
panel.className = 'dot-menu-panel';
panel.dataset.position = position;
panel.setAttribute('role', 'menu');
wrap.appendChild(trigger);
wrap.appendChild(panel);
hostEl.innerHTML = '';
hostEl.appendChild(wrap);
const close = () => {
panel.classList.remove('is-open');
wrap.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
detailsEl.open = false;
});
};
const open = () => {
panel.classList.add('is-open');
wrap.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
};
const toggle = () => {
if (panel.classList.contains('is-open')) {
close();
} else {
open();
}
};
const onTriggerClick = (event) => {
event.stopPropagation();
toggle();
};
const onDocumentClick = (event) => {
if (!wrap.contains(event.target)) {
close();
}
};
const onDocumentKeyDown = (event) => {
if (event.key === 'Escape') {
close();
}
};
const onViewportChange = () => {
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
positionSubmenuPanel(detailsEl);
});
};
trigger.addEventListener('click', onTriggerClick);
panel.addEventListener('click', (event) => event.stopPropagation());
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeyDown);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
renderItems(panel, currentItems, close);
const updateItems = (newItems) => {
currentItems = normalizeItems(newItems);
renderItems(panel, currentItems, close);
};
const destroy = () => {
close();
trigger.removeEventListener('click', onTriggerClick);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeyDown);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
if (wrap.parentNode) {
wrap.parentNode.removeChild(wrap);
}
};
return { open, close, destroy, updateItems };
}

865
www/js/post-composer.js Normal file
View File

@@ -0,0 +1,865 @@
/* Inlined stubs for sovereign_browser — WebKit's custom sovereign://
* scheme does not resolve relative ES module imports, so we inline the
* dependencies instead of importing from separate .mjs files. These
* stubs are sufficient for the chat composer (no uploads, no preview). */
function uploadToAllServers() {
return Promise.reject(new Error('Blossom uploads are not available in sovereign_browser'));
}
function getBlobUrl(sha256, ext) {
return `blossom://${sha256}.${ext}`;
}
function htmlFormatText(text = '') {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
function hydrateNostrEntities() {
/* No-op in sovereign_browser. */
}
function debounce(fn, delay = 250) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function getFileExtension(file) {
const fromName = String(file?.name || '').split('.').pop();
if (fromName && fromName !== file?.name) return fromName.toLowerCase();
const fromType = String(file?.type || '').split('/').pop();
return fromType ? fromType.toLowerCase() : '';
}
function getCaretCharacterOffsetWithin(element) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
function setCaretByCharacterOffset(element, offset) {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
let currentOffset = 0;
let found = false;
const walk = (node) => {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const textLength = node.textContent?.length || 0;
if (currentOffset + textLength >= offset) {
range.setStart(node, Math.max(0, offset - currentOffset));
range.collapse(true);
found = true;
} else {
currentOffset += textLength;
}
return;
}
for (const child of node.childNodes) {
walk(child);
if (found) return;
}
};
walk(element);
if (!found) {
range.selectNodeContents(element);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
function detectMentionQuery(text, caretOffset) {
const left = text.slice(0, caretOffset);
const match = left.match(/(^|\s)@([a-z0-9_.-]{1,32})$/i);
if (!match) return null;
const query = match[2] || '';
const start = left.length - query.length - 1;
const end = caretOffset;
return { query, start, end };
}
function escapeHtml(text = '') {
return String(text)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/'/g, '\u0026#39;');
}
function toNpub(pubkeyOrNpub) {
if (!pubkeyOrNpub) return null;
if (String(pubkeyOrNpub).startsWith('npub1')) return String(pubkeyOrNpub);
try {
return window?.NostrTools?.nip19?.npubEncode?.(pubkeyOrNpub) || null;
} catch {
return null;
}
}
function positionDropdownNearCaret(hostEl, dropdownEl) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0).cloneRange();
range.collapse(true);
let rect = range.getBoundingClientRect();
if (!rect || (!rect.width && !rect.height)) {
const hostRect = hostEl.getBoundingClientRect();
rect = {
left: hostRect.left + 8,
top: hostRect.top + hostRect.height - 8,
bottom: hostRect.top + hostRect.height - 8
};
}
dropdownEl.style.left = `${Math.max(8, rect.left)}px`;
dropdownEl.style.top = `${Math.max(8, rect.bottom + 6)}px`;
}
function filterMentionCandidates(followedProfiles, query) {
const q = String(query || '').toLowerCase().trim();
if (!q) return [];
return followedProfiles
.filter((p) => {
const name = String(p?.name || '').toLowerCase();
const display = String(p?.display_name || '').toLowerCase();
const nip05 = String(p?.nip05 || '').toLowerCase();
const shortPk = String(p?.pubkey || '').slice(0, 12).toLowerCase();
return name.includes(q) || display.includes(q) || nip05.includes(q) || shortPk.includes(q);
})
.slice(0, 8);
}
function createUploadIcon(kind = 'upload') {
const normalizedKind = kind === 'image' ? 'image' : 'upload';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'post-composer-upload-icon';
if (normalizedKind === 'image') {
btn.classList.add('is-image-icon');
}
if (normalizedKind === 'image') {
btn.title = 'Attach image';
btn.setAttribute('aria-label', 'Attach image');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" ry="2"/>
<circle cx="9" cy="10" r="1.5"/>
<path d="M6 16l4-4 3 3 3-3 2 2"/>
</svg>
`;
return btn;
}
btn.title = 'Upload file';
btn.setAttribute('aria-label', 'Upload file');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<path d="M7 10l5-5 5 5"/>
<path d="M12 5v12"/>
</svg>
`;
return btn;
}
window.mountComposer = function mountComposer(hostEl, options = {}) {
if (!hostEl) throw new Error('mountComposer requires a host element');
let followedProfiles = Array.isArray(options.followedProfiles) ? options.followedProfiles.slice() : [];
const showUploadIcon = options.showUploadIcon !== false;
const showPreview = options.showPreview !== false;
const autoHideOnSubmit = options.autoHideOnSubmit === true;
const submitOnEnter = options.submitOnEnter === true;
const hideOnEscape = options.hideOnEscape === true;
const alwaysShowSendButton = options.alwaysShowSendButton === true;
const clearBeforeSubmit = options.clearBeforeSubmit !== false;
const enableHistory = options.enableHistory !== false;
const historyKey = typeof options.historyKey === 'string' && options.historyKey.trim()
? options.historyKey.trim()
: 'post_composer_history_v1';
const historyMaxEntries = Math.max(1, Math.floor(Number(options.historyMaxEntries || 50)));
const layout = options.layout === 'inline' ? 'inline' : 'default';
const popupMode = options.popupMode === true;
const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null;
const onFileAttach = typeof options.onFileAttach === 'function' ? options.onFileAttach : null;
const uploadMode = options.uploadMode === 'direct-image' ? 'direct-image' : 'blossom-url';
const uploadIcon = options.uploadIcon === 'image' ? 'image' : (uploadMode === 'direct-image' ? 'image' : 'upload');
const defaultFileAccept = uploadMode === 'direct-image'
? 'image/*'
: 'image/*,video/*,audio/*,.pdf,.txt,.md,.zip';
const fileAccept = typeof options.fileAccept === 'string' && options.fileAccept.trim()
? options.fileAccept.trim()
: defaultFileAccept;
hostEl.setAttribute('contenteditable', 'true');
let wrapper = hostEl.parentElement;
if (!wrapper || !wrapper.classList.contains('post-composer-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'post-composer-wrapper';
hostEl.parentNode?.insertBefore(wrapper, hostEl);
wrapper.appendChild(hostEl);
}
const editorShell = document.createElement('div');
editorShell.className = 'post-composer-editor-shell';
wrapper.insertBefore(editorShell, hostEl);
editorShell.appendChild(hostEl);
const dragOverlay = document.createElement('div');
dragOverlay.className = 'post-composer-drag-overlay';
dragOverlay.textContent = 'Drop files to upload';
editorShell.appendChild(dragOverlay);
const uploadStatus = document.createElement('div');
uploadStatus.className = 'post-composer-uploading';
uploadStatus.style.display = 'none';
editorShell.appendChild(uploadStatus);
let uploadButton = null;
if (showUploadIcon) {
uploadButton = createUploadIcon(uploadIcon);
editorShell.appendChild(uploadButton);
}
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = fileAccept;
fileInput.style.display = 'none';
wrapper.appendChild(fileInput);
const previewEl = document.createElement('div');
previewEl.className = 'post-composer-preview';
previewEl.innerHTML = `
<div class="post-composer-preview-label">Preview</div>
<div class="post-composer-preview-content divPostContent"></div>
`;
const previewContentEl = previewEl.querySelector('.post-composer-preview-content');
const sendButton = document.createElement('button');
sendButton.type = 'button';
sendButton.className = 'post-composer-send-btn';
if (alwaysShowSendButton) sendButton.classList.add('is-persistent-visible');
sendButton.textContent = 'Send';
sendButton.disabled = true;
if (layout === 'inline') {
wrapper.classList.add('post-composer-wrapper--inline');
if (showPreview) wrapper.appendChild(previewEl);
const inlineRow = document.createElement('div');
inlineRow.className = 'post-composer-inline-row';
wrapper.appendChild(inlineRow);
inlineRow.appendChild(editorShell);
inlineRow.appendChild(sendButton);
} else if (popupMode) {
wrapper.classList.add('post-composer-wrapper--popup');
const scrollBody = document.createElement('div');
scrollBody.className = 'post-composer-popup-body';
scrollBody.appendChild(editorShell);
if (showPreview) scrollBody.appendChild(previewEl);
wrapper.appendChild(scrollBody);
const stickyFooter = document.createElement('div');
stickyFooter.className = 'post-composer-popup-footer';
stickyFooter.appendChild(sendButton);
wrapper.appendChild(stickyFooter);
} else {
if (showPreview) wrapper.appendChild(previewEl);
wrapper.appendChild(sendButton);
}
const mentionDropdown = document.createElement('div');
mentionDropdown.className = 'post-composer-mention-dropdown';
document.body.appendChild(mentionDropdown);
let dragDepth = 0;
let uploadingCount = 0;
let externallyDisabled = options.disabled === true;
let pendingAttachments = [];
let mentionState = {
query: '',
start: -1,
end: -1,
candidates: [],
activeIndex: 0,
visible: false
};
function loadHistory() {
if (!enableHistory) return [];
try {
const raw = localStorage.getItem(historyKey);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
} catch {
return [];
}
}
function saveHistory(nextHistory) {
if (!enableHistory) return;
try {
localStorage.setItem(historyKey, JSON.stringify(nextHistory));
} catch {
// Ignore localStorage failures.
}
}
let history = loadHistory();
let historyIndex = -1;
let historyDraft = '';
function pushToHistory(text) {
if (!enableHistory) return;
const normalized = String(text || '').trim();
if (!normalized) return;
history = history.filter((item) => item !== normalized);
history.unshift(normalized);
if (history.length > historyMaxEntries) {
history = history.slice(0, historyMaxEntries);
}
saveHistory(history);
historyIndex = -1;
historyDraft = '';
}
function resetHistoryBrowseState() {
historyIndex = -1;
historyDraft = '';
}
function setEditorText(text = '') {
hostEl.innerText = String(text || '');
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
renderPreview();
updateSendButtonVisibility();
}
function updateUploadingUi() {
if (uploadingCount > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}`;
return;
}
if (pendingAttachments.length > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `${pendingAttachments.length} image${pendingAttachments.length > 1 ? 's' : ''} attached`;
return;
}
uploadStatus.style.display = 'none';
uploadStatus.textContent = '';
}
function hideMentionDropdown() {
mentionState.visible = false;
mentionState.candidates = [];
mentionState.activeIndex = 0;
mentionDropdown.classList.remove('is-visible');
mentionDropdown.innerHTML = '';
}
function renderMentionDropdown() {
if (!mentionState.visible || mentionState.candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionDropdown.innerHTML = mentionState.candidates
.map((profile, idx) => {
const display = profile.display_name || profile.name || `${String(profile.pubkey || '').slice(0, 8)}`;
const handle = profile.name ? `@${profile.name}` : `${String(profile.pubkey || '').slice(0, 12)}`;
const avatar = profile.picture
? `<img class="post-composer-mention-avatar" src="${escapeHtml(profile.picture)}" alt="" onerror="this.style.visibility='hidden'" />`
: `<div class="post-composer-mention-avatar" style="background:var(--muted-color);"></div>`;
return `
<div class="post-composer-mention-item ${idx === mentionState.activeIndex ? 'is-active' : ''}" data-index="${idx}">
${avatar}
<div class="post-composer-mention-meta">
<div class="post-composer-mention-name">${escapeHtml(display)}</div>
<div class="post-composer-mention-handle">${escapeHtml(handle)}</div>
</div>
</div>
`;
})
.join('');
mentionDropdown.classList.add('is-visible');
positionDropdownNearCaret(hostEl, mentionDropdown);
}
function replaceMentionWithProfile(profile) {
const npub = toNpub(profile?.npub || profile?.pubkey);
if (!npub) return;
const currentText = hostEl.innerText || '';
if (mentionState.start < 0 || mentionState.end < mentionState.start) return;
const replacement = `nostr:${npub}`;
const nextText = currentText.slice(0, mentionState.start) + replacement + currentText.slice(mentionState.end);
hostEl.innerText = nextText;
const nextCaret = mentionState.start + replacement.length;
setCaretByCharacterOffset(hostEl, nextCaret);
hideMentionDropdown();
queuePreviewRender();
updateSendButtonVisibility();
}
function updateMentionStateFromCaret() {
const text = hostEl.innerText || '';
const caret = getCaretCharacterOffsetWithin(hostEl);
const mention = detectMentionQuery(text, caret);
if (!mention) {
hideMentionDropdown();
return;
}
const candidates = filterMentionCandidates(followedProfiles, mention.query);
if (candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionState = {
...mention,
candidates,
activeIndex: 0,
visible: true
};
renderMentionDropdown();
}
function normalizePendingAttachments(attachments) {
if (!Array.isArray(attachments)) return [];
return attachments
.map((att) => {
const dataUrl = String(att?.dataUrl || '').trim();
if (!dataUrl.startsWith('data:image/')) return null;
return {
id: String(att?.id || crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`),
name: String(att?.name || 'image').trim() || 'image',
mimeType: String(att?.mimeType || '').trim() || dataUrl.slice(5, dataUrl.indexOf(';') > 0 ? dataUrl.indexOf(';') : undefined),
dataUrl
};
})
.filter(Boolean);
}
async function insertUploadedFiles(files) {
const arr = Array.from(files || []).filter((f) => f && f.size > 0);
if (arr.length === 0) return;
uploadingCount += arr.length;
updateUploadingUi();
try {
if (uploadMode === 'direct-image') {
if (!onFileAttach) {
console.warn('[post-composer] uploadMode "direct-image" is enabled but onFileAttach is missing.');
return;
}
const attached = await onFileAttach(arr);
const normalized = normalizePendingAttachments(attached);
if (normalized.length > 0) {
pendingAttachments = [...pendingAttachments, ...normalized];
}
updateSendButtonVisibility();
return;
}
for (const file of arr) {
const { sha256 } = await uploadToAllServers(file);
const ext = getFileExtension(file);
const url = getBlobUrl(sha256, ext);
hostEl.focus();
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
document.execCommand('insertText', false, `${prefix}${url}\n`);
}
} finally {
uploadingCount = Math.max(0, uploadingCount - arr.length);
updateUploadingUi();
queuePreviewRender();
}
}
function updateSendButtonVisibility() {
const hasContent = !!(hostEl.innerText || '').trim();
const hasAttachments = pendingAttachments.length > 0;
const canSubmit = hasContent || hasAttachments;
const interactive = canSubmit && uploadingCount === 0 && !externallyDisabled;
sendButton.classList.toggle('is-visible', canSubmit);
if (alwaysShowSendButton) sendButton.classList.add('is-visible');
sendButton.disabled = !interactive;
sendButton.tabIndex = (canSubmit || alwaysShowSendButton) ? 0 : -1;
}
function clearComposerContent() {
hostEl.innerHTML = '';
hostEl.innerText = '';
pendingAttachments = [];
renderPreview();
updateUploadingUi();
updateSendButtonVisibility();
hideMentionDropdown();
resetHistoryBrowseState();
}
function hideComposer() {
wrapper.style.display = 'none';
}
function showComposer() {
wrapper.style.display = '';
}
function renderPreview() {
if (!showPreview || !previewContentEl) return;
const raw = hostEl.innerText || '';
if (!raw.trim()) {
previewEl.classList.remove('is-visible');
previewContentEl.innerHTML = '';
return;
}
previewEl.classList.add('is-visible');
previewContentEl.innerHTML = htmlFormatText(raw);
hydrateNostrEntities(previewContentEl);
}
const queuePreviewRender = debounce(renderPreview, 250);
function onInput() {
queuePreviewRender();
updateMentionStateFromCaret();
updateSendButtonVisibility();
if (historyIndex !== -1) {
resetHistoryBrowseState();
}
}
function onKeydown(event) {
if (event.key === 'Tab' && !event.shiftKey) {
const hasContent = !!(hostEl.innerText || '').trim();
if (hasContent && !mentionState.visible) {
event.preventDefault();
sendButton.focus();
return;
}
}
const isEnterKey = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter';
if (isEnterKey && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && !mentionState.visible) {
event.preventDefault();
event.stopPropagation();
onSendClick();
return;
}
if (submitOnEnter && event.key === 'Enter' && !event.shiftKey && !mentionState.visible) {
event.preventDefault();
onSendClick();
return;
}
if (event.key === 'Escape') {
if (mentionState.visible) {
event.preventDefault();
hideMentionDropdown();
return;
}
if (hideOnEscape) {
event.preventDefault();
hideComposer();
return;
}
}
if (!mentionState.visible && enableHistory) {
const currentText = hostEl.innerText || '';
const caretOffset = getCaretCharacterOffsetWithin(hostEl);
const singleLine = !currentText.includes('\n');
if (event.key === 'ArrowUp') {
const atStart = caretOffset <= 0;
if (atStart || singleLine) {
event.preventDefault();
if (historyIndex === -1) {
historyDraft = currentText;
}
if (historyIndex < history.length - 1) {
historyIndex += 1;
setEditorText(history[historyIndex] || '');
}
return;
}
}
if (event.key === 'ArrowDown') {
const atEnd = caretOffset >= currentText.length;
if ((atEnd || singleLine) && historyIndex >= 0) {
event.preventDefault();
historyIndex -= 1;
if (historyIndex < 0) {
setEditorText(historyDraft || '');
} else {
setEditorText(history[historyIndex] || '');
}
return;
}
}
}
if (!mentionState.visible) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex + 1) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex - 1 + mentionState.candidates.length) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const selected = mentionState.candidates[mentionState.activeIndex];
if (selected) replaceMentionWithProfile(selected);
return;
}
}
function onSelectionChange() {
if (!mentionState.visible) return;
updateMentionStateFromCaret();
}
function onDocumentClick(event) {
if (wrapper.contains(event.target) || mentionDropdown.contains(event.target)) return;
hideMentionDropdown();
}
function onDragEnter(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth += 1;
editorShell.classList.add('is-dragover');
}
function onDragOver(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) editorShell.classList.remove('is-dragover');
}
async function onDrop(event) {
if (!event.dataTransfer?.files?.length) return;
event.preventDefault();
dragDepth = 0;
editorShell.classList.remove('is-dragover');
await insertUploadedFiles(event.dataTransfer.files);
}
async function onFileInputChange(event) {
const files = event.target?.files;
if (!files || files.length === 0) return;
await insertUploadedFiles(files);
fileInput.value = '';
}
async function onPaste(event) {
const clipboardItems = Array.from(event.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
if (imageFiles.length === 0) return;
event.preventDefault();
await insertUploadedFiles(imageFiles);
}
function onMentionDropdownMouseDown(event) {
const item = event.target.closest('.post-composer-mention-item');
if (!item) return;
event.preventDefault();
const index = Number(item.dataset.index);
if (Number.isNaN(index)) return;
const selected = mentionState.candidates[index];
if (selected) replaceMentionWithProfile(selected);
}
async function onSendClick() {
if (sendButton.disabled) return;
if (!onSubmit) return;
const text = (hostEl.innerText || '').trim();
const attachments = pendingAttachments.slice();
if (!text && attachments.length === 0) return;
const previousText = text;
const previousAttachments = attachments;
if (clearBeforeSubmit) {
clearComposerContent();
if (!autoHideOnSubmit) {
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
}
try {
const submitted = await onSubmit(previousText, { attachments: previousAttachments });
if (submitted === false) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
}
return;
}
if (previousText) {
pushToHistory(previousText);
}
if (autoHideOnSubmit) {
if (!clearBeforeSubmit) {
clearComposerContent();
}
hideComposer();
} else if (!clearBeforeSubmit) {
pendingAttachments = [];
queuePreviewRender();
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
} catch (error) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
}
console.error('[post-composer] Submit failed:', error);
}
}
hostEl.addEventListener('input', onInput);
hostEl.addEventListener('keydown', onKeydown);
hostEl.addEventListener('dragenter', onDragEnter);
hostEl.addEventListener('dragover', onDragOver);
hostEl.addEventListener('dragleave', onDragLeave);
hostEl.addEventListener('drop', onDrop);
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('mousedown', onDocumentClick);
hostEl.addEventListener('paste', onPaste);
if (uploadButton) {
uploadButton.addEventListener('click', () => fileInput.click());
}
fileInput.addEventListener('change', onFileInputChange);
mentionDropdown.addEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.addEventListener('click', onSendClick);
queuePreviewRender();
updateSendButtonVisibility();
return {
refresh(nextOptions = {}) {
if (Array.isArray(nextOptions.followedProfiles)) {
followedProfiles = nextOptions.followedProfiles.slice();
}
queuePreviewRender();
},
clear() {
clearComposerContent();
},
setDisabled(disabled) {
externallyDisabled = !!disabled;
updateSendButtonVisibility();
},
show() {
showComposer();
},
hide() {
hideComposer();
},
isUploading() {
return uploadingCount > 0;
},
destroy() {
hostEl.removeEventListener('input', onInput);
hostEl.removeEventListener('keydown', onKeydown);
hostEl.removeEventListener('dragenter', onDragEnter);
hostEl.removeEventListener('dragover', onDragOver);
hostEl.removeEventListener('dragleave', onDragLeave);
hostEl.removeEventListener('drop', onDrop);
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('mousedown', onDocumentClick);
hostEl.removeEventListener('paste', onPaste);
fileInput.removeEventListener('change', onFileInputChange);
mentionDropdown.removeEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.removeEventListener('click', onSendClick);
if (mentionDropdown.parentNode) mentionDropdown.parentNode.removeChild(mentionDropdown);
if (uploadButton?.parentNode) uploadButton.parentNode.removeChild(uploadButton);
if (dragOverlay.parentNode) dragOverlay.parentNode.removeChild(dragOverlay);
if (uploadStatus.parentNode) uploadStatus.parentNode.removeChild(uploadStatus);
if (previewEl.parentNode) previewEl.parentNode.removeChild(previewEl);
if (sendButton.parentNode) sendButton.parentNode.removeChild(sendButton);
if (editorShell.parentNode) editorShell.parentNode.removeChild(editorShell);
if (fileInput.parentNode) fileInput.parentNode.removeChild(fileInput);
}
};
}
window.mountComposerDefault = window.mountComposer;

865
www/js/post-composer.mjs Normal file
View File

@@ -0,0 +1,865 @@
/* Inlined stubs for sovereign_browser — WebKit's custom sovereign://
* scheme does not resolve relative ES module imports, so we inline the
* dependencies instead of importing from separate .mjs files. These
* stubs are sufficient for the chat composer (no uploads, no preview). */
function uploadToAllServers() {
return Promise.reject(new Error('Blossom uploads are not available in sovereign_browser'));
}
function getBlobUrl(sha256, ext) {
return `blossom://${sha256}.${ext}`;
}
function htmlFormatText(text = '') {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
function hydrateNostrEntities() {
/* No-op in sovereign_browser. */
}
function debounce(fn, delay = 250) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function getFileExtension(file) {
const fromName = String(file?.name || '').split('.').pop();
if (fromName && fromName !== file?.name) return fromName.toLowerCase();
const fromType = String(file?.type || '').split('/').pop();
return fromType ? fromType.toLowerCase() : '';
}
function getCaretCharacterOffsetWithin(element) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
function setCaretByCharacterOffset(element, offset) {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
let currentOffset = 0;
let found = false;
const walk = (node) => {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const textLength = node.textContent?.length || 0;
if (currentOffset + textLength >= offset) {
range.setStart(node, Math.max(0, offset - currentOffset));
range.collapse(true);
found = true;
} else {
currentOffset += textLength;
}
return;
}
for (const child of node.childNodes) {
walk(child);
if (found) return;
}
};
walk(element);
if (!found) {
range.selectNodeContents(element);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
function detectMentionQuery(text, caretOffset) {
const left = text.slice(0, caretOffset);
const match = left.match(/(^|\s)@([a-z0-9_.-]{1,32})$/i);
if (!match) return null;
const query = match[2] || '';
const start = left.length - query.length - 1;
const end = caretOffset;
return { query, start, end };
}
function escapeHtml(text = '') {
return String(text)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/'/g, '\u0026#39;');
}
function toNpub(pubkeyOrNpub) {
if (!pubkeyOrNpub) return null;
if (String(pubkeyOrNpub).startsWith('npub1')) return String(pubkeyOrNpub);
try {
return window?.NostrTools?.nip19?.npubEncode?.(pubkeyOrNpub) || null;
} catch {
return null;
}
}
function positionDropdownNearCaret(hostEl, dropdownEl) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0).cloneRange();
range.collapse(true);
let rect = range.getBoundingClientRect();
if (!rect || (!rect.width && !rect.height)) {
const hostRect = hostEl.getBoundingClientRect();
rect = {
left: hostRect.left + 8,
top: hostRect.top + hostRect.height - 8,
bottom: hostRect.top + hostRect.height - 8
};
}
dropdownEl.style.left = `${Math.max(8, rect.left)}px`;
dropdownEl.style.top = `${Math.max(8, rect.bottom + 6)}px`;
}
function filterMentionCandidates(followedProfiles, query) {
const q = String(query || '').toLowerCase().trim();
if (!q) return [];
return followedProfiles
.filter((p) => {
const name = String(p?.name || '').toLowerCase();
const display = String(p?.display_name || '').toLowerCase();
const nip05 = String(p?.nip05 || '').toLowerCase();
const shortPk = String(p?.pubkey || '').slice(0, 12).toLowerCase();
return name.includes(q) || display.includes(q) || nip05.includes(q) || shortPk.includes(q);
})
.slice(0, 8);
}
function createUploadIcon(kind = 'upload') {
const normalizedKind = kind === 'image' ? 'image' : 'upload';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'post-composer-upload-icon';
if (normalizedKind === 'image') {
btn.classList.add('is-image-icon');
}
if (normalizedKind === 'image') {
btn.title = 'Attach image';
btn.setAttribute('aria-label', 'Attach image');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" ry="2"/>
<circle cx="9" cy="10" r="1.5"/>
<path d="M6 16l4-4 3 3 3-3 2 2"/>
</svg>
`;
return btn;
}
btn.title = 'Upload file';
btn.setAttribute('aria-label', 'Upload file');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<path d="M7 10l5-5 5 5"/>
<path d="M12 5v12"/>
</svg>
`;
return btn;
}
export function mountComposer(hostEl, options = {}) {
if (!hostEl) throw new Error('mountComposer requires a host element');
let followedProfiles = Array.isArray(options.followedProfiles) ? options.followedProfiles.slice() : [];
const showUploadIcon = options.showUploadIcon !== false;
const showPreview = options.showPreview !== false;
const autoHideOnSubmit = options.autoHideOnSubmit === true;
const submitOnEnter = options.submitOnEnter === true;
const hideOnEscape = options.hideOnEscape === true;
const alwaysShowSendButton = options.alwaysShowSendButton === true;
const clearBeforeSubmit = options.clearBeforeSubmit !== false;
const enableHistory = options.enableHistory !== false;
const historyKey = typeof options.historyKey === 'string' && options.historyKey.trim()
? options.historyKey.trim()
: 'post_composer_history_v1';
const historyMaxEntries = Math.max(1, Math.floor(Number(options.historyMaxEntries || 50)));
const layout = options.layout === 'inline' ? 'inline' : 'default';
const popupMode = options.popupMode === true;
const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null;
const onFileAttach = typeof options.onFileAttach === 'function' ? options.onFileAttach : null;
const uploadMode = options.uploadMode === 'direct-image' ? 'direct-image' : 'blossom-url';
const uploadIcon = options.uploadIcon === 'image' ? 'image' : (uploadMode === 'direct-image' ? 'image' : 'upload');
const defaultFileAccept = uploadMode === 'direct-image'
? 'image/*'
: 'image/*,video/*,audio/*,.pdf,.txt,.md,.zip';
const fileAccept = typeof options.fileAccept === 'string' && options.fileAccept.trim()
? options.fileAccept.trim()
: defaultFileAccept;
hostEl.setAttribute('contenteditable', 'true');
let wrapper = hostEl.parentElement;
if (!wrapper || !wrapper.classList.contains('post-composer-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'post-composer-wrapper';
hostEl.parentNode?.insertBefore(wrapper, hostEl);
wrapper.appendChild(hostEl);
}
const editorShell = document.createElement('div');
editorShell.className = 'post-composer-editor-shell';
wrapper.insertBefore(editorShell, hostEl);
editorShell.appendChild(hostEl);
const dragOverlay = document.createElement('div');
dragOverlay.className = 'post-composer-drag-overlay';
dragOverlay.textContent = 'Drop files to upload';
editorShell.appendChild(dragOverlay);
const uploadStatus = document.createElement('div');
uploadStatus.className = 'post-composer-uploading';
uploadStatus.style.display = 'none';
editorShell.appendChild(uploadStatus);
let uploadButton = null;
if (showUploadIcon) {
uploadButton = createUploadIcon(uploadIcon);
editorShell.appendChild(uploadButton);
}
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = fileAccept;
fileInput.style.display = 'none';
wrapper.appendChild(fileInput);
const previewEl = document.createElement('div');
previewEl.className = 'post-composer-preview';
previewEl.innerHTML = `
<div class="post-composer-preview-label">Preview</div>
<div class="post-composer-preview-content divPostContent"></div>
`;
const previewContentEl = previewEl.querySelector('.post-composer-preview-content');
const sendButton = document.createElement('button');
sendButton.type = 'button';
sendButton.className = 'post-composer-send-btn';
if (alwaysShowSendButton) sendButton.classList.add('is-persistent-visible');
sendButton.textContent = 'Send';
sendButton.disabled = true;
if (layout === 'inline') {
wrapper.classList.add('post-composer-wrapper--inline');
if (showPreview) wrapper.appendChild(previewEl);
const inlineRow = document.createElement('div');
inlineRow.className = 'post-composer-inline-row';
wrapper.appendChild(inlineRow);
inlineRow.appendChild(editorShell);
inlineRow.appendChild(sendButton);
} else if (popupMode) {
wrapper.classList.add('post-composer-wrapper--popup');
const scrollBody = document.createElement('div');
scrollBody.className = 'post-composer-popup-body';
scrollBody.appendChild(editorShell);
if (showPreview) scrollBody.appendChild(previewEl);
wrapper.appendChild(scrollBody);
const stickyFooter = document.createElement('div');
stickyFooter.className = 'post-composer-popup-footer';
stickyFooter.appendChild(sendButton);
wrapper.appendChild(stickyFooter);
} else {
if (showPreview) wrapper.appendChild(previewEl);
wrapper.appendChild(sendButton);
}
const mentionDropdown = document.createElement('div');
mentionDropdown.className = 'post-composer-mention-dropdown';
document.body.appendChild(mentionDropdown);
let dragDepth = 0;
let uploadingCount = 0;
let externallyDisabled = options.disabled === true;
let pendingAttachments = [];
let mentionState = {
query: '',
start: -1,
end: -1,
candidates: [],
activeIndex: 0,
visible: false
};
function loadHistory() {
if (!enableHistory) return [];
try {
const raw = localStorage.getItem(historyKey);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
} catch {
return [];
}
}
function saveHistory(nextHistory) {
if (!enableHistory) return;
try {
localStorage.setItem(historyKey, JSON.stringify(nextHistory));
} catch {
// Ignore localStorage failures.
}
}
let history = loadHistory();
let historyIndex = -1;
let historyDraft = '';
function pushToHistory(text) {
if (!enableHistory) return;
const normalized = String(text || '').trim();
if (!normalized) return;
history = history.filter((item) => item !== normalized);
history.unshift(normalized);
if (history.length > historyMaxEntries) {
history = history.slice(0, historyMaxEntries);
}
saveHistory(history);
historyIndex = -1;
historyDraft = '';
}
function resetHistoryBrowseState() {
historyIndex = -1;
historyDraft = '';
}
function setEditorText(text = '') {
hostEl.innerText = String(text || '');
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
renderPreview();
updateSendButtonVisibility();
}
function updateUploadingUi() {
if (uploadingCount > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}`;
return;
}
if (pendingAttachments.length > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `${pendingAttachments.length} image${pendingAttachments.length > 1 ? 's' : ''} attached`;
return;
}
uploadStatus.style.display = 'none';
uploadStatus.textContent = '';
}
function hideMentionDropdown() {
mentionState.visible = false;
mentionState.candidates = [];
mentionState.activeIndex = 0;
mentionDropdown.classList.remove('is-visible');
mentionDropdown.innerHTML = '';
}
function renderMentionDropdown() {
if (!mentionState.visible || mentionState.candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionDropdown.innerHTML = mentionState.candidates
.map((profile, idx) => {
const display = profile.display_name || profile.name || `${String(profile.pubkey || '').slice(0, 8)}`;
const handle = profile.name ? `@${profile.name}` : `${String(profile.pubkey || '').slice(0, 12)}`;
const avatar = profile.picture
? `<img class="post-composer-mention-avatar" src="${escapeHtml(profile.picture)}" alt="" onerror="this.style.visibility='hidden'" />`
: `<div class="post-composer-mention-avatar" style="background:var(--muted-color);"></div>`;
return `
<div class="post-composer-mention-item ${idx === mentionState.activeIndex ? 'is-active' : ''}" data-index="${idx}">
${avatar}
<div class="post-composer-mention-meta">
<div class="post-composer-mention-name">${escapeHtml(display)}</div>
<div class="post-composer-mention-handle">${escapeHtml(handle)}</div>
</div>
</div>
`;
})
.join('');
mentionDropdown.classList.add('is-visible');
positionDropdownNearCaret(hostEl, mentionDropdown);
}
function replaceMentionWithProfile(profile) {
const npub = toNpub(profile?.npub || profile?.pubkey);
if (!npub) return;
const currentText = hostEl.innerText || '';
if (mentionState.start < 0 || mentionState.end < mentionState.start) return;
const replacement = `nostr:${npub}`;
const nextText = currentText.slice(0, mentionState.start) + replacement + currentText.slice(mentionState.end);
hostEl.innerText = nextText;
const nextCaret = mentionState.start + replacement.length;
setCaretByCharacterOffset(hostEl, nextCaret);
hideMentionDropdown();
queuePreviewRender();
updateSendButtonVisibility();
}
function updateMentionStateFromCaret() {
const text = hostEl.innerText || '';
const caret = getCaretCharacterOffsetWithin(hostEl);
const mention = detectMentionQuery(text, caret);
if (!mention) {
hideMentionDropdown();
return;
}
const candidates = filterMentionCandidates(followedProfiles, mention.query);
if (candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionState = {
...mention,
candidates,
activeIndex: 0,
visible: true
};
renderMentionDropdown();
}
function normalizePendingAttachments(attachments) {
if (!Array.isArray(attachments)) return [];
return attachments
.map((att) => {
const dataUrl = String(att?.dataUrl || '').trim();
if (!dataUrl.startsWith('data:image/')) return null;
return {
id: String(att?.id || crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`),
name: String(att?.name || 'image').trim() || 'image',
mimeType: String(att?.mimeType || '').trim() || dataUrl.slice(5, dataUrl.indexOf(';') > 0 ? dataUrl.indexOf(';') : undefined),
dataUrl
};
})
.filter(Boolean);
}
async function insertUploadedFiles(files) {
const arr = Array.from(files || []).filter((f) => f && f.size > 0);
if (arr.length === 0) return;
uploadingCount += arr.length;
updateUploadingUi();
try {
if (uploadMode === 'direct-image') {
if (!onFileAttach) {
console.warn('[post-composer] uploadMode "direct-image" is enabled but onFileAttach is missing.');
return;
}
const attached = await onFileAttach(arr);
const normalized = normalizePendingAttachments(attached);
if (normalized.length > 0) {
pendingAttachments = [...pendingAttachments, ...normalized];
}
updateSendButtonVisibility();
return;
}
for (const file of arr) {
const { sha256 } = await uploadToAllServers(file);
const ext = getFileExtension(file);
const url = getBlobUrl(sha256, ext);
hostEl.focus();
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
document.execCommand('insertText', false, `${prefix}${url}\n`);
}
} finally {
uploadingCount = Math.max(0, uploadingCount - arr.length);
updateUploadingUi();
queuePreviewRender();
}
}
function updateSendButtonVisibility() {
const hasContent = !!(hostEl.innerText || '').trim();
const hasAttachments = pendingAttachments.length > 0;
const canSubmit = hasContent || hasAttachments;
const interactive = canSubmit && uploadingCount === 0 && !externallyDisabled;
sendButton.classList.toggle('is-visible', canSubmit);
if (alwaysShowSendButton) sendButton.classList.add('is-visible');
sendButton.disabled = !interactive;
sendButton.tabIndex = (canSubmit || alwaysShowSendButton) ? 0 : -1;
}
function clearComposerContent() {
hostEl.innerHTML = '';
hostEl.innerText = '';
pendingAttachments = [];
renderPreview();
updateUploadingUi();
updateSendButtonVisibility();
hideMentionDropdown();
resetHistoryBrowseState();
}
function hideComposer() {
wrapper.style.display = 'none';
}
function showComposer() {
wrapper.style.display = '';
}
function renderPreview() {
if (!showPreview || !previewContentEl) return;
const raw = hostEl.innerText || '';
if (!raw.trim()) {
previewEl.classList.remove('is-visible');
previewContentEl.innerHTML = '';
return;
}
previewEl.classList.add('is-visible');
previewContentEl.innerHTML = htmlFormatText(raw);
hydrateNostrEntities(previewContentEl);
}
const queuePreviewRender = debounce(renderPreview, 250);
function onInput() {
queuePreviewRender();
updateMentionStateFromCaret();
updateSendButtonVisibility();
if (historyIndex !== -1) {
resetHistoryBrowseState();
}
}
function onKeydown(event) {
if (event.key === 'Tab' && !event.shiftKey) {
const hasContent = !!(hostEl.innerText || '').trim();
if (hasContent && !mentionState.visible) {
event.preventDefault();
sendButton.focus();
return;
}
}
const isEnterKey = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter';
if (isEnterKey && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && !mentionState.visible) {
event.preventDefault();
event.stopPropagation();
onSendClick();
return;
}
if (submitOnEnter && event.key === 'Enter' && !event.shiftKey && !mentionState.visible) {
event.preventDefault();
onSendClick();
return;
}
if (event.key === 'Escape') {
if (mentionState.visible) {
event.preventDefault();
hideMentionDropdown();
return;
}
if (hideOnEscape) {
event.preventDefault();
hideComposer();
return;
}
}
if (!mentionState.visible && enableHistory) {
const currentText = hostEl.innerText || '';
const caretOffset = getCaretCharacterOffsetWithin(hostEl);
const singleLine = !currentText.includes('\n');
if (event.key === 'ArrowUp') {
const atStart = caretOffset <= 0;
if (atStart || singleLine) {
event.preventDefault();
if (historyIndex === -1) {
historyDraft = currentText;
}
if (historyIndex < history.length - 1) {
historyIndex += 1;
setEditorText(history[historyIndex] || '');
}
return;
}
}
if (event.key === 'ArrowDown') {
const atEnd = caretOffset >= currentText.length;
if ((atEnd || singleLine) && historyIndex >= 0) {
event.preventDefault();
historyIndex -= 1;
if (historyIndex < 0) {
setEditorText(historyDraft || '');
} else {
setEditorText(history[historyIndex] || '');
}
return;
}
}
}
if (!mentionState.visible) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex + 1) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex - 1 + mentionState.candidates.length) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const selected = mentionState.candidates[mentionState.activeIndex];
if (selected) replaceMentionWithProfile(selected);
return;
}
}
function onSelectionChange() {
if (!mentionState.visible) return;
updateMentionStateFromCaret();
}
function onDocumentClick(event) {
if (wrapper.contains(event.target) || mentionDropdown.contains(event.target)) return;
hideMentionDropdown();
}
function onDragEnter(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth += 1;
editorShell.classList.add('is-dragover');
}
function onDragOver(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) editorShell.classList.remove('is-dragover');
}
async function onDrop(event) {
if (!event.dataTransfer?.files?.length) return;
event.preventDefault();
dragDepth = 0;
editorShell.classList.remove('is-dragover');
await insertUploadedFiles(event.dataTransfer.files);
}
async function onFileInputChange(event) {
const files = event.target?.files;
if (!files || files.length === 0) return;
await insertUploadedFiles(files);
fileInput.value = '';
}
async function onPaste(event) {
const clipboardItems = Array.from(event.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
if (imageFiles.length === 0) return;
event.preventDefault();
await insertUploadedFiles(imageFiles);
}
function onMentionDropdownMouseDown(event) {
const item = event.target.closest('.post-composer-mention-item');
if (!item) return;
event.preventDefault();
const index = Number(item.dataset.index);
if (Number.isNaN(index)) return;
const selected = mentionState.candidates[index];
if (selected) replaceMentionWithProfile(selected);
}
async function onSendClick() {
if (sendButton.disabled) return;
if (!onSubmit) return;
const text = (hostEl.innerText || '').trim();
const attachments = pendingAttachments.slice();
if (!text && attachments.length === 0) return;
const previousText = text;
const previousAttachments = attachments;
if (clearBeforeSubmit) {
clearComposerContent();
if (!autoHideOnSubmit) {
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
}
try {
const submitted = await onSubmit(previousText, { attachments: previousAttachments });
if (submitted === false) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
}
return;
}
if (previousText) {
pushToHistory(previousText);
}
if (autoHideOnSubmit) {
if (!clearBeforeSubmit) {
clearComposerContent();
}
hideComposer();
} else if (!clearBeforeSubmit) {
pendingAttachments = [];
queuePreviewRender();
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
} catch (error) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
}
console.error('[post-composer] Submit failed:', error);
}
}
hostEl.addEventListener('input', onInput);
hostEl.addEventListener('keydown', onKeydown);
hostEl.addEventListener('dragenter', onDragEnter);
hostEl.addEventListener('dragover', onDragOver);
hostEl.addEventListener('dragleave', onDragLeave);
hostEl.addEventListener('drop', onDrop);
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('mousedown', onDocumentClick);
hostEl.addEventListener('paste', onPaste);
if (uploadButton) {
uploadButton.addEventListener('click', () => fileInput.click());
}
fileInput.addEventListener('change', onFileInputChange);
mentionDropdown.addEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.addEventListener('click', onSendClick);
queuePreviewRender();
updateSendButtonVisibility();
return {
refresh(nextOptions = {}) {
if (Array.isArray(nextOptions.followedProfiles)) {
followedProfiles = nextOptions.followedProfiles.slice();
}
queuePreviewRender();
},
clear() {
clearComposerContent();
},
setDisabled(disabled) {
externallyDisabled = !!disabled;
updateSendButtonVisibility();
},
show() {
showComposer();
},
hide() {
hideComposer();
},
isUploading() {
return uploadingCount > 0;
},
destroy() {
hostEl.removeEventListener('input', onInput);
hostEl.removeEventListener('keydown', onKeydown);
hostEl.removeEventListener('dragenter', onDragEnter);
hostEl.removeEventListener('dragover', onDragOver);
hostEl.removeEventListener('dragleave', onDragLeave);
hostEl.removeEventListener('drop', onDrop);
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('mousedown', onDocumentClick);
hostEl.removeEventListener('paste', onPaste);
fileInput.removeEventListener('change', onFileInputChange);
mentionDropdown.removeEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.removeEventListener('click', onSendClick);
if (mentionDropdown.parentNode) mentionDropdown.parentNode.removeChild(mentionDropdown);
if (uploadButton?.parentNode) uploadButton.parentNode.removeChild(uploadButton);
if (dragOverlay.parentNode) dragOverlay.parentNode.removeChild(dragOverlay);
if (uploadStatus.parentNode) uploadStatus.parentNode.removeChild(uploadStatus);
if (previewEl.parentNode) previewEl.parentNode.removeChild(previewEl);
if (sendButton.parentNode) sendButton.parentNode.removeChild(sendButton);
if (editorShell.parentNode) editorShell.parentNode.removeChild(editorShell);
if (fileInput.parentNode) fileInput.parentNode.removeChild(fileInput);
}
};
}
export default mountComposer;

8
www/profile.css Normal file
View File

@@ -0,0 +1,8 @@
/* Profile page — sovereign://profile
* Imports the shared sovereign:// base theme. */
@import url("sovereign://sovereign-base.css");
.profile-picture {
max-width: 120px; max-height: 120px; border-radius: 8px;
float: right; margin-left: 16px;
}

25
www/profile.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Profile</title>
<link rel="stylesheet" href="sovereign://profile.css">
</head>
<body>
<h1>Profile</h1>
<div id="profile-body">
<p class="note">Loading profile…</p>
</div>
<p class="note">
This page shows data cached in the local SQLite database from the
bootstrap relay fetch. If fields show
<span class="missing">(not found)</span>, the relay fetch hasn't
completed or the user has no events of that kind on the bootstrap relays.
</p>
<script src="sovereign://profile.js"></script>
</body>
</html>

102
www/profile.js Normal file
View File

@@ -0,0 +1,102 @@
/* Profile page — sovereign://profile
*
* Fetches profile data (identity, kind 0 metadata, kind 3 contacts,
* kind 10002 relay list, npub, QR code URL) from sovereign://profile/data
* (JSON) and renders it.
*/
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 { resolve(JSON.parse(x.responseText)); }
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 == null ? '' : String(s);
return d.innerHTML;
}
function infoRow(label, value) {
return '<div class="info-row"><span>' + esc(label) + '</span>' +
'<span class="info">' + (value == null || value === '' ?
'<span class="missing">(not found)</span>' : esc(value)) +
'</span></div>';
}
function renderProfile(d) {
var c = document.getElementById('profile-body');
if (!d || !d.pubkey) {
c.innerHTML = '<p class="note">No identity loaded. Log in to see your profile.</p>';
return;
}
var html = '';
/* Profile picture (kind 0). */
if (d.picture) {
html += '<img class="profile-picture" src="' + esc(d.picture) +
'" alt="Profile picture">';
}
/* QR code for the npub URI. */
if (d.qr_url) {
html += '<div class="qr"><img src="' + esc(d.qr_url) +
'" alt="npub QR code"></div>';
}
/* Identity section. */
html += '<h2>Identity</h2>';
html += infoRow('Pubkey', d.pubkey_short);
html += infoRow('npub URI', d.npub);
html += infoRow('Method', d.method);
html += infoRow('Signing', d.signing);
/* Profile (kind 0). */
html += '<h2>Profile (kind 0)</h2>';
if (!d.kind0_present) {
html += '<p class="missing">(no kind 0 event in database)</p>';
}
html += infoRow('Name', d.name);
html += infoRow('Display Name', d.display_name);
if (d.about) {
html += '<div class="info-row"><span>About</span>' +
'<span class="info" style="max-width:400px;">' + esc(d.about) +
'</span></div>';
}
html += infoRow('Website', d.website);
html += infoRow('Lightning (lud16)', d.lud16);
html += infoRow('Last updated', d.kind0_created);
html += infoRow('Events in DB', d.kind0_count);
/* Contacts (kind 3). */
html += '<h2>Contacts (kind 3)</h2>';
if (!d.kind3_present) {
html += '<p class="missing">(no kind 3 event in database)</p>';
}
html += infoRow('Following', (d.contact_count != null ?
d.contact_count + ' pubkey(s)' : ''));
html += infoRow('Last updated', d.kind3_created);
html += infoRow('Events in DB', d.kind3_count);
/* Relay list (kind 10002). */
html += '<h2>Relay List (kind 10002)</h2>';
html += infoRow('Events in DB', d.kind10002_count);
c.innerHTML = html;
}
sovereignGet('sovereign://profile/data?_=' + Date.now())
.then(function(d) { renderProfile(d); })
.catch(function(e) {
document.getElementById('profile-body').innerHTML =
'<p class="note">Failed to load profile: ' + esc(e.message) + '</p>';
});

3
www/settings.css Normal file
View File

@@ -0,0 +1,3 @@
/* Settings page — sovereign://settings
* Imports the shared sovereign:// base theme. */
@import url("sovereign://sovereign-base.css");

155
www/settings.html Normal file
View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings</title>
<link rel="stylesheet" href="sovereign://settings.css">
</head>
<body>
<h1>Settings</h1>
<h2>Appearance</h2>
<div class="setting">
<div><div class="setting-name">Dark mode (Night)</div></div>
<div class="toggle" data-feature="theme_dark"
onclick="toggle('theme_dark')"></div>
</div>
<h2>Tabs</h2>
<div class="setting">
<div><div class="setting-name">Restore session on startup</div></div>
<div class="toggle" data-feature="restore_session"
onclick="toggle('restore_session')"></div>
</div>
<div class="field">
<div><div class="setting-name">New tab URL</div></div>
<div><input type="text" id="new_tab_url">
<button class="save-btn" onclick="save('new_tab_url')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Tab bar position</div></div>
<div><select id="tab_bar_position">
<option value="top">Top</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
<button class="save-btn" onclick="save('tab_bar_position')">Save</button></div>
</div>
<div class="setting">
<div><div class="setting-name">Show tab close buttons</div></div>
<div class="toggle" data-feature="show_tab_close_buttons"
onclick="toggle('show_tab_close_buttons')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Middle-click to close tab</div></div>
<div class="toggle" data-feature="middle_click_close"
onclick="toggle('middle_click_close')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Ctrl+Tab to switch tabs</div></div>
<div class="toggle" data-feature="ctrl_tab_switch"
onclick="toggle('ctrl_tab_switch')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Allow drag to reorder tabs</div></div>
<div class="toggle" data-feature="tab_drag_reorder"
onclick="toggle('tab_drag_reorder')"></div>
</div>
<div class="field">
<div><div class="setting-name">Maximum tabs</div></div>
<div><input type="number" id="max_tabs" min="1" max="999">
<button class="save-btn" onclick="save('max_tabs')">Save</button></div>
</div>
<h2>Keyboard Shortcuts</h2>
<div id="shortcuts-section"></div>
<div class="field">
<div><div class="setting-name">Reset all shortcuts</div></div>
<div><button class="save-btn" id="sc-reset-all">Reset All</button></div>
</div>
<h2>Agent Server</h2>
<div class="setting">
<div><div class="setting-name">Enable agent server</div></div>
<div class="toggle" data-feature="agent_server_enabled"
onclick="toggle('agent_server_enabled')"></div>
</div>
<div class="field">
<div><div class="setting-name">Agent server port</div></div>
<div><input type="number" id="agent_server_port" min="1" max="65535">
<button class="save-btn" onclick="save('agent_server_port')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Allowed origins</div></div>
<div><input type="text" id="agent_allowed_origins">
<button class="save-btn" onclick="save('agent_allowed_origins')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Login timeout (ms)</div></div>
<div><input type="number" id="agent_login_timeout_ms" min="0" max="300000">
<button class="save-btn" onclick="save('agent_login_timeout_ms')">Save</button></div>
</div>
<h2>Bootstrap Relays</h2>
<div class="field">
<div><div class="setting-name">Relay URLs</div></div>
<div><textarea id="bootstrap_relays" rows="4" cols="40"></textarea>
<button class="save-btn" onclick="save('bootstrap_relays')">Save</button></div>
</div>
<h2>Search</h2>
<div class="field">
<div><div class="setting-name">Search engine</div></div>
<div><select id="search_engine"></select>
<button class="save-btn" onclick="save('search_engine')">Save</button></div>
</div>
<h2>Security</h2>
<div class="setting">
<div><div class="setting-name">Developer Tools (Inspector)</div></div>
<div class="toggle" data-feature="dev_extras"
onclick="toggle('dev_extras')"></div>
</div>
<div class="setting">
<div><div class="setting-name">File Access from file://</div></div>
<div class="toggle" data-feature="file_access"
onclick="toggle('file_access')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Universal Access from file://</div></div>
<div class="toggle" data-feature="universal_access"
onclick="toggle('universal_access')"></div>
</div>
<div class="setting">
<div><div class="setting-name">CORS Enforcement</div></div>
<div class="toggle off" style="opacity:0.5"></div>
</div>
<div class="setting">
<div><div class="setting-name">TLS Certificate Check</div></div>
<div class="toggle off" style="opacity:0.5"></div>
</div>
<div id="status" class="status"></div>
<script src="sovereign://settings.js"></script>
</body>
</html>

325
www/settings.js Normal file
View File

@@ -0,0 +1,325 @@
/* Settings page — sovereign://settings
*
* Fetches all settings values from sovereign://settings/config (JSON) on
* page load and populates the DOM. Toggles, saves, and shortcut capture
* use the existing sovereign://settings/set endpoint.
*/
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 { resolve(JSON.parse(x.responseText)); }
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 == null ? '' : String(s);
return d.innerHTML;
}
function showStatus(cls, msg) {
var s = document.getElementById('status');
s.className = 'status ' + cls + ' show';
s.textContent = msg;
}
/* Set a toggle element's on/off class. */
function setToggleClass(el, on) {
el.classList.remove('on', 'off');
el.classList.add(on ? 'on' : 'off');
}
/* Apply the settings/config JSON to the DOM. */
function applyConfig(d) {
/* Toggles — each has a data-feature attribute identifying the key. */
var toggleMap = {
theme_dark: d.theme_dark,
restore_session: d.restore_session,
show_tab_close_buttons: d.show_tab_close_buttons,
middle_click_close: d.middle_click_close,
ctrl_tab_switch: d.ctrl_tab_switch,
tab_drag_reorder: d.tab_drag_reorder,
agent_server_enabled: d.agent_server_enabled,
dev_extras: d.dev_extras,
file_access: d.file_access,
universal_access: d.universal_access
};
document.querySelectorAll('.toggle[data-feature]').forEach(function(el) {
var key = el.getAttribute('data-feature');
if (key === 'theme_dark') el.id = 'toggle_theme_dark';
setToggleClass(el, !!toggleMap[key]);
});
/* Text/number inputs. */
if (d.new_tab_url != null)
document.getElementById('new_tab_url').value = d.new_tab_url;
if (d.max_tabs != null)
document.getElementById('max_tabs').value = d.max_tabs;
if (d.agent_server_port != null)
document.getElementById('agent_server_port').value = d.agent_server_port;
if (d.agent_allowed_origins != null)
document.getElementById('agent_allowed_origins').value = d.agent_allowed_origins;
if (d.agent_login_timeout_ms != null)
document.getElementById('agent_login_timeout_ms').value = d.agent_login_timeout_ms;
if (d.bootstrap_relays != null)
document.getElementById('bootstrap_relays').value = d.bootstrap_relays;
/* Tab bar position select. */
if (d.tab_bar_position != null) {
var sel = document.getElementById('tab_bar_position');
sel.value = d.tab_bar_position;
}
/* Search engine select. */
var se = document.getElementById('search_engine');
se.innerHTML = '';
(d.search_engines || []).forEach(function(eng) {
var opt = document.createElement('option');
opt.value = eng.id;
opt.textContent = eng.name;
if (eng.id === d.search_engine) opt.selected = true;
se.appendChild(opt);
});
/* Keyboard shortcuts. */
renderShortcuts(d.shortcuts || []);
}
/* ── Keyboard shortcut capture ──────────────────────────────────── */
var capturingAction = null;
var capturingDisplay = null;
function jsKeyToGdk(e) {
var k = e.key;
if (k.length === 1) {
var c = k.toLowerCase();
if (c >= 'a' && c <= 'z') return c;
if (c >= '0' && c <= '9') return c;
if (k === ',') return 'comma';
if (k === ' ') return 'space';
if (k === '.') return 'period';
if (k === '/') return 'slash';
if (k === '-') return 'minus';
if (k === '=') return 'equal';
}
var special = {
'Tab':'Tab','Enter':'Return','Escape':'Escape',
'Backspace':'BackSpace','Delete':'Delete',
'Home':'Home','End':'End',
'PageUp':'Page_Up','PageDown':'Page_Down',
'ArrowLeft':'Left','ArrowRight':'Right',
'ArrowUp':'Up','ArrowDown':'Down',
'F1':'F1','F2':'F2','F3':'F3','F4':'F4','F5':'F5','F6':'F6',
'F7':'F7','F8':'F8','F9':'F9','F10':'F10','F11':'F11','F12':'F12'
};
if (special[k]) return special[k];
return null;
}
function buildAccel(e) {
var parts = [];
if (e.ctrlKey) parts.push('<Control>');
if (e.altKey) parts.push('<Alt>');
if (e.shiftKey) parts.push('<Shift>');
if (e.metaKey) parts.push('<Super>');
var gdkKey = jsKeyToGdk(e);
if (!gdkKey) return null;
parts.push(gdkKey);
return parts.join('');
}
function displayAccel(accel) {
return accel.replace(/<Control>/g,'Ctrl+')
.replace(/<Alt>/g,'Alt+')
.replace(/<Shift>/g,'Shift+')
.replace(/<Super>/g,'Super+');
}
function renderShortcuts(shortcuts) {
var c = document.getElementById('shortcuts-section');
var html = '';
shortcuts.forEach(function(sc) {
var accel = sc.accel || '';
html += '<div class="field shortcut-row" data-action="' + esc(sc.id) +
'" data-accel="' + esc(accel) + '">';
html += '<div><div class="setting-name">' + esc(sc.label) + '</div></div>';
html += '<div><span class="shortcut-display" id="sc_' + esc(sc.id) + '">' +
esc(displayAccel(accel)) + '</span>';
html += ' <button class="save-btn sc-change" data-action="' + esc(sc.id) +
'">Change</button>';
html += ' <button class="save-btn sc-reset" data-action="' + esc(sc.id) +
'">Reset</button></div>';
html += '</div>';
});
c.innerHTML = html;
bindShortcutButtons();
checkDuplicates();
}
function checkDuplicates() {
var rows = document.querySelectorAll('.shortcut-row');
var seen = {};
rows.forEach(function(r) {
r.classList.remove('duplicate');
var a = r.getAttribute('data-accel');
if (a) {
if (seen[a]) seen[a].push(r);
else seen[a] = [r];
}
});
Object.keys(seen).forEach(function(a) {
if (seen[a].length > 1) {
seen[a].forEach(function(r) { r.classList.add('duplicate'); });
}
});
}
function bindShortcutButtons() {
document.querySelectorAll('.sc-change').forEach(function(btn) {
btn.addEventListener('click', function() {
var action = btn.getAttribute('data-action');
var disp = document.getElementById('sc_' + action);
if (!disp) return;
if (capturingDisplay) capturingDisplay.classList.remove('capturing');
capturingAction = action;
capturingDisplay = disp;
disp.setAttribute('data-prev', disp.textContent);
disp.classList.add('capturing');
disp.textContent = 'Press keys…';
});
});
document.querySelectorAll('.sc-reset').forEach(function(btn) {
btn.addEventListener('click', function() {
resetShortcut(btn.getAttribute('data-action'));
});
});
var ra = document.getElementById('sc-reset-all');
if (ra) ra.onclick = resetAllShortcuts;
}
function commitShortcut(action, accel) {
sovereignGet('sovereign://settings/set?key=shortcut.' + action +
'&value=' + encodeURIComponent(accel))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', action + ' = ' + displayAccel(accel));
setTimeout(function() { location.reload(); }, 400);
}
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
function resetShortcut(action) {
sovereignGet('sovereign://settings/set?key=shortcut.reset&action=' + action)
.then(function(d) {
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', action + ' reset to default');
setTimeout(function() { location.reload(); }, 400);
}
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
function resetAllShortcuts() {
sovereignGet('sovereign://settings/set?key=shortcut.reset_all')
.then(function(d) {
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', 'All shortcuts reset to defaults');
setTimeout(function() { location.reload(); }, 400);
}
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
document.addEventListener('keydown', function(e) {
if (!capturingAction) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
capturingDisplay.classList.remove('capturing');
capturingDisplay.textContent =
capturingDisplay.getAttribute('data-prev') || '';
capturingAction = null;
capturingDisplay = null;
showStatus('ok', 'Cancelled');
return;
}
var bareMod = ['Control','Shift','Alt','Meta'].indexOf(e.key) >= 0;
if (bareMod) return;
var accel = buildAccel(e);
if (!accel) return;
capturingDisplay.classList.remove('capturing');
capturingDisplay.textContent = displayAccel(accel);
var action = capturingAction;
capturingAction = null;
capturingDisplay = null;
commitShortcut(action, accel);
}, true);
/* ── Toggle / save actions ──────────────────────────────────────── */
function toggle(feature) {
if (feature === 'theme_dark') {
var h = document.documentElement;
var isDark = h.classList.contains('dark');
if (isDark) h.classList.remove('dark');
else h.classList.add('dark');
var btn = document.getElementById('toggle_theme_dark');
if (btn) setToggleClass(btn, !isDark);
var x = new XMLHttpRequest();
x.open('GET', 'sovereign://settings/set?feature=' + feature, true);
x.send();
showStatus('ok', 'theme_dark: ' + (isDark ? 'OFF' : 'ON'));
return;
}
var x = new XMLHttpRequest();
x.open('GET', 'sovereign://settings/set?feature=' + feature, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try {
var d = JSON.parse(x.responseText);
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', d.key + ': ' + (d.value ? 'ON' : 'OFF'));
setTimeout(function() { location.reload(); }, 300);
}
} catch (e) { showStatus('err', 'Error: ' + e.message); }
};
x.send();
}
function save(key) {
var el = document.getElementById(key);
var val = el ? el.value : '';
var x = new XMLHttpRequest();
x.open('GET', 'sovereign://settings/set?key=' + encodeURIComponent(key) +
'&value=' + encodeURIComponent(val), true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try {
var d = JSON.parse(x.responseText);
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', d.key + ' = ' + d.value);
if (d.reload) setTimeout(function() { location.reload(); }, 300);
}
} catch (e) { showStatus('err', 'Error: ' + e.message); }
};
x.send();
}
/* ── Init ───────────────────────────────────────────────────────── */
sovereignGet('sovereign://settings/config?_=' + Date.now())
.then(function(d) { applyConfig(d); })
.catch(function(e) {
showStatus('err', 'Failed to load settings: ' + e.message);
});

146
www/sovereign-base.css Normal file
View File

@@ -0,0 +1,146 @@
/* Shared sovereign:// page CSS (theme variables, base elements, buttons).
* Mirrors sovereign_page_css() in src/nostr_bridge.c so the embedded
* file-based pages stay consistent with the legacy C-string pages.
* Linked by settings.html, bookmarks.html, profile.html, agents/config.html. */
:root {
--font: monospace;
--primary: #000000;
--secondary: #ffffff;
--accent: #ff0000;
--muted: #dddddd;
--border: var(--muted);
--radius: 5px;
--bg: var(--secondary);
--fg: var(--primary);
--img-gray: 100%;
--img-gray-hover: 20%;
color-scheme: light;
}
html.dark {
--primary: #ffffff;
--secondary: #000000;
--accent: #ff0000;
--muted: #777777;
--border: var(--muted);
color-scheme: dark;
}
* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; }
html { transition: background-color 0.2s ease, color 0.2s ease; }
body {
color: var(--fg); background: var(--bg);
max-width: 800px; margin: 40px auto; padding: 20px;
line-height: 1.5;
}
h1 {
color: var(--primary); text-align: center;
border-bottom: 2px solid var(--primary);
padding-bottom: 8px; margin-bottom: 20px;
}
h2 {
color: var(--primary); margin-top: 30px; margin-bottom: 10px;
border-bottom: 1px solid var(--border); padding-bottom: 4px;
}
h3 { color: var(--primary); margin-top: 20px; margin-bottom: 8px; }
a { color: var(--accent); text-decoration: none; transition: 0.2s; }
a:hover { text-decoration: underline; }
img { filter: grayscale(var(--img-gray)); transition: filter 0.2s; }
img:hover { filter: grayscale(var(--img-gray-hover)); }
.note { color: var(--muted); font-size: 12px; margin: 10px 0; }
.info { color: var(--muted); font-size: 12px; word-break: break-all; }
.missing { color: var(--muted); font-style: italic; }
.info-row {
display: flex; justify-content: space-between;
gap: 20px; padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.info-row span:first-child { font-weight: bold; flex-shrink: 0; }
.setting, .field {
display: flex; justify-content: space-between;
align-items: center; gap: 20px; padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.setting-name { font-weight: bold; }
.setting-desc { color: var(--muted); font-size: 12px; margin-top: 4px; }
.toggle {
position: relative; width: 50px; height: 24px;
background: var(--muted); border-radius: 12px; cursor: pointer;
transition: background 0.2s; flex-shrink: 0;
}
.toggle.on { background: var(--primary); }
.toggle.off { background: var(--muted); }
.toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 20px; height: 20px; border-radius: 50%; background: var(--secondary);
transition: transform 0.2s;
}
.toggle.on::after { transform: translateX(26px); }
input, select, textarea {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 10px; font-family: var(--font); font-size: 13px;
min-width: 200px;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent); outline: none;
}
.btn, .save-btn {
background: var(--bg); color: var(--primary);
border: 1px solid var(--primary); border-radius: var(--radius);
padding: 6px 16px; cursor: pointer; font-family: var(--font);
font-size: 13px; font-weight: bold; margin-left: 8px;
transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.btn:hover, .save-btn:hover { border-color: var(--accent); }
.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-del { border-color: var(--accent); color: var(--accent); }
.btn-del:hover { background: var(--accent); color: var(--secondary); }
.status {
padding: 8px; margin: 10px 0; border-radius: var(--radius);
display: none; border: 1px solid var(--border);
}
.status.show { display: block; }
.status.ok { border-color: var(--primary); color: var(--primary); }
.status.err { border-color: var(--accent); color: var(--accent); }
.shortcut-display {
display: inline-block; min-width: 120px;
padding: 4px 10px; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); font-family: var(--font); font-size: 13px;
text-align: center;
}
.shortcut-display.capturing {
border-color: var(--accent);
animation: pulse 1s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
.shortcut-row.duplicate .shortcut-display { border-color: var(--accent); }
.sc-change, .sc-reset { min-width: 70px; }
.qr { text-align: center; margin: 12px 0; }
.qr img {
image-rendering: pixelated; border: 4px solid var(--secondary);
border-radius: 4px;
}
.theme-toggle {
position: fixed; top: 20px; right: 20px;
display: flex; align-items: center; gap: 8px; cursor: pointer;
text-decoration: none; user-select: none;
font-size: 12px; color: var(--muted); user-select: none;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 4px 10px; background: var(--bg); z-index: 10;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.bm {
display: flex; justify-content: space-between; align-items: center;
gap: 20px; padding: 8px 0; border-bottom: 1px solid var(--border);
}
.bm-url { color: var(--accent); text-decoration: none; word-break: break-all; }
.bm-url:hover { text-decoration: underline; }
.bm-title { color: var(--primary); font-weight: bold; }
.bm-meta { color: var(--muted); font-size: 11px; }
.actions { display: flex; gap: 8px; flex-shrink: 0; }
.form { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
.empty { color: var(--muted); font-style: italic; padding: 8px 0; }
.dir-header {
display: flex; justify-content: space-between;
align-items: center; gap: 20px;
}

6
www/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
www/vendor/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long