v0.0.27 - Embedded agent: tabbed chat, sidebar mode, skills, Nostr sync, embedded web content, bug fixes
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
17
Makefile
17
Makefile
@@ -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
|
||||
|
||||
36
README.md
36
README.md
@@ -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
|
||||
|
||||
132
embed_web_files.sh
Executable file
132
embed_web_files.sh
Executable 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"
|
||||
176
plans/chat-page-discrete-events.md
Normal file
176
plans/chat-page-discrete-events.md
Normal 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).
|
||||
174
plans/chat-sidebar-tabbed.md
Normal file
174
plans/chat-sidebar-tabbed.md
Normal 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).
|
||||
353
plans/cross-project-agent-sync.md
Normal file
353
plans/cross-project-agent-sync.md
Normal 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
320
plans/embedded-agent.md
Normal 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.
|
||||
179
plans/embedded-web-content.md
Normal file
179
plans/embedded-web-content.md
Normal 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 |
|
||||
181
plans/system-prompt-as-skill.md
Normal file
181
plans/system-prompt-as-skill.md
Normal 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
49
src/agent_chat.c
Normal 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
47
src/agent_chat.h
Normal 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
177
src/agent_chat_store.c
Normal 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
89
src/agent_chat_store.h
Normal 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
799
src/agent_conversations.c
Normal 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
144
src/agent_conversations.h
Normal 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
488
src/agent_fs_tools.c
Normal 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
42
src/agent_fs_tools.h
Normal 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
417
src/agent_llm.c
Normal 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
86
src/agent_llm.h
Normal 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
484
src/agent_loop.c
Normal 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
81
src/agent_loop.h
Normal 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 */
|
||||
@@ -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
833
src/agent_skills.c
Normal 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
204
src/agent_skills.h
Normal 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
46
src/agent_tool_catalog.h
Normal 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 */
|
||||
@@ -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
394
src/db.c
@@ -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;
|
||||
}
|
||||
|
||||
72
src/db.h
72
src/db.h
@@ -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
|
||||
|
||||
37
src/main.c
37
src/main.c
@@ -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
|
||||
|
||||
1794
src/nostr_bridge.c
1794
src/nostr_bridge.c
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
127
src/settings.c
127
src/settings.c
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
/*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 ──────────────────────────────────────── */
|
||||
|
||||
@@ -43,6 +43,7 @@ typedef enum {
|
||||
SHORTCUT_NEW_IDENTITY,
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_TOGGLE_INSPECTOR,
|
||||
SHORTCUT_TOGGLE_SIDEBAR,
|
||||
SHORTCUT_COUNT
|
||||
} shortcut_action_t;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
442
www/agents/chat.css
Normal 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
63
www/agents/chat.html
Normal 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 ‘+ New Chat’ 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">↻</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">↻</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
998
www/agents/chat.js
Normal 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, "\\'") + '\')">✎</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
3
www/agents/config.css
Normal 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
107
www/agents/config.html
Normal 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
289
www/agents/config.js
Normal 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
8
www/bookmarks.css
Normal 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
36
www/bookmarks.html
Normal 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
115
www/bookmarks.js
Normal 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
142
www/css/dot-menu.css
Normal 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
321
www/css/post-composer.css
Normal 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
232
www/js/dot-menu.js
Normal 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
232
www/js/dot-menu.mjs
Normal 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
865
www/js/post-composer.js
Normal 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
865
www/js/post-composer.mjs
Normal 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
8
www/profile.css
Normal 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
25
www/profile.html
Normal 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
102
www/profile.js
Normal 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
3
www/settings.css
Normal 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
155
www/settings.html
Normal 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
325
www/settings.js
Normal 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
146
www/sovereign-base.css
Normal 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
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
3
www/vendor/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user