diff --git a/plans/document-page.md b/plans/document-page.md new file mode 100644 index 0000000..e02eac3 --- /dev/null +++ b/plans/document-page.md @@ -0,0 +1,201 @@ +# Document Page (`www/document.html`) + +## Overview + +A new page, derived from [`www/ai.html`](../www/ai.html:1), that lets the user co-author a long-form Nostr note with an LLM assistant. The page starts as an encrypted draft (**kind 30024**) and can be promoted to a published article (**kind 30023**). + +Layout is a 3-column page: + +```mermaid +flowchart LR + A[Documents + Skills
left pane] --> B[Chat thread
center pane] + B --> C[Document column
title + markdown editor
markdown/plaintext toggle] + C -.autosave 30024.-> R[(Nostr relays)] + B -.chat turn JSON.-> LLM[LLM] + LLM -.document + chat.-> B + LLM -.document.-> C + B -.autosave 30078 doc-chat:dtag.-> R +``` + +**There is no "Conversations" list.** The chat log is bound 1:1 to the document. Selecting a document loads its entire past chat; creating a new document creates a new empty chat. + +## Approach summary (from Q&A) + +| Topic | Decision | +|------|----------| +| LLM response format | **Option A** — `response_format: {type: 'json_object'}` returning `{ "chat": "...", "document": "..." }`. LLM always returns the full document. | +| Skill strategy | **Option C (hybrid)** — a hardcoded page-level wrapper enforces the JSON contract; user-selected kind 31123 skills are concatenated *above* it as style/tone layers. | +| Document column behavior | **Editable** textarea (same feel as [`www/note.html`](../www/note.html:1)); both the user and the LLM can write to it. | +| View toggle | Reuse [`dot-menu`](../www/js/dot-menu.mjs:1): "View as markdown", "View as plaintext", "Copy document". | + +## 1. HTML structure changes + +Starting from the current copy of [`www/ai.html`](../www/ai.html:1) at [`www/document.html`](../www/document.html:1): + +- Change `` and header text from "AI" to "Document". +- Rename `#divAiLayout` to a 3-column flex row: + - Left: `#divDocumentsPane` (renamed from `#divAiConversationsPane`) — **Documents list on top, Skills on bottom**. The "Conversations" list is removed entirely. + - Center: `#divAiChatPane` (unchanged internals; just displays the chat log for the currently-selected document). + - **New right pane:** `#divAiDocumentPane` containing: + - Header row with title input, publish-as-30023 button, and a `.doc-menu-host` for the dot-menu. + - Summary input + hashtags input (single line each). + - Image URL input (optional). + - `#taDocument` markdown textarea (main editor). + - `#divDocumentPreview` hidden div for rendered-markdown view mode. + - Small status line ("Saved / UNSAVED", current kind, current `d` tag). +- Empty state: when no document is selected, the chat input is disabled and the document pane shows a placeholder "Select or create a document". +- CSS: `#divAiDocumentPane` is `flex: 1.2; min-width: 420px;` similar styling to the chat pane. + +## 2. Document model (ported from note.html) + +Port the following from [`www/note.html`](../www/note.html:552) but render the list in the **left column** (not the sidenav): + +- `OBJ_NOTES` map keyed by `d` tag. +- `renderDocumentsList()` — renders `OBJ_NOTES` as a vertical list in `#divDocumentsList` (replaces `#divAiConversationsList`). Each row: title (or "Untitled"), small meta (`kind`, last-edited), delete button. +- `LoadNote(d)` — populates `#taDocument`, title/summary/hashtags/image inputs, sets `CURRENT_NOTE = d`, re-renders preview via `marked`, AND triggers `loadDocChat(d)` (see §5.3). +- `newDocument()` — assigns `CURRENT_NOTE = now()`, clears inputs and chat thread. No publish yet; publish happens on first real save. +- `DeleteNote(d)` — kind 5 delete event for the document. Also deletes the paired `doc-chat:<d>` kind-30078 event. +- `Publish30024Note()` — autosave path (unchanged logic). +- `Publish30023Note(d)` — promote draft → published (unchanged logic). + +The sidenav drawer keeps only relay / blossom / AI-config sections — no notes table duplicated there. + +## 3. Autosave + +Mirror note.html's autosave: + +- `LastText` baseline vs. current textarea value. +- Footer shows "UNSAVED" vs. "Saved". +- Debounced save on textarea input (e.g. 1500 ms idle). Immediate save when the LLM writes a new document. Immediate save on blur if dirty. +- On save, `Publish30024Note()` keeps the same `d` tag. + +## 4. Markdown / plaintext / copy dot-menu + +A tiny helper inside `document.html` (no new module needed) uses [`mountDotMenu`](../www/js/dot-menu.mjs:1) on `.doc-menu-host`: + +```text +┌───────── Document ⋯ ─────────┐ +│ View as markdown │ +│ View as plaintext │ +│ ────────────────────────────│ +│ Copy document │ +└──────────────────────────────┘ +``` + +- **Plaintext** (default): show `#taDocument`, hide `#divDocumentPreview`. +- **Markdown**: hide textarea, show `#divDocumentPreview` with `marked.parse(taDocument.value)` (purified via `DOMPurify` already loaded by the page). +- **Copy document**: clipboard copy of the raw textarea value. + +The current view mode lives in a `documentViewMode` variable (not per-message like messaging-ui — it's a single document). The pattern is copied from `setMessageViewMode` / `renderBubbleContent` in [`www/js/messaging-ui.mjs`](../www/js/messaging-ui.mjs:36). + +## 5. LLM ↔ document wiring + +### 5.1 Hardcoded skill wrapper + +Add a constant inside `document.html`: + +```js +const DOCUMENT_EDITOR_WRAPPER = ` +system: +You are collaborating with the user on a single long-form markdown document. +Every reply MUST be valid JSON with exactly these fields: + +{ + "chat": "<short conversational reply, plain text>", + "document": "<the FULL updated markdown document, or null if unchanged>" +} + +Rules: +- Never include commentary outside the JSON. +- Always return the ENTIRE document in "document" when you make ANY edit, even small ones. +- Set "document" to null (or omit it) when the user only asked a question and no edit is needed. +- Preserve the user's existing structure unless they ask you to restructure. +- Keep markdown formatting (headings, lists, code fences) intact. + +user: +The current document is below, inside <document></document>. The user's new message follows. + +<document> +{{document}} +</document> + +User message: +{{message}} +`; +``` + +This wrapper is concatenated *after* any user-selected kind 31123 skills (so user skills set style/tone, wrapper enforces JSON contract last and therefore wins on conflicting instructions — per the existing "last skill wins" rule in [`plans/ai-skills-integration.md`](ai-skills-integration.md:5)). + +### 5.2 Send flow changes + +In the existing `sendPrompt()` path (the one already ported from ai.html): + +1. Build `combinedSystem` from selected skills as today, then append `DOCUMENT_EDITOR_WRAPPER`'s system portion. +2. Resolve `{{document}}` placeholder with `taDocument.value` and `{{message}}` with the user's input. +3. Call the chat endpoint using [`sendAiChatJson`](../www/js/ai-chat.mjs:72) (already supports `response_format: { type: 'json_object' }`) — replaces the current plain `callOpenAICompatibleChat` path only on this page. +4. On success, `result.json` is `{ chat, document }`: + - Append `chat` as the assistant message in the thread. + - If `document` is a non-empty string and differs from `taDocument.value`, replace the textarea, re-render preview if in markdown view, trigger immediate autosave. + - If `document` is `null`/omitted, skip the document update (chat-only turn). +5. On malformed JSON, fall back to displaying `result.content` as the assistant message and do NOT touch the document. Show an inline warning chip under the message. + +### 5.3 Chat storage — bound 1:1 to the document + +There is **no separate Conversations concept**. The chat log for a document is persisted as its own addressable event: + +| Field | Value | +|-------|-------| +| `kind` | `30078` | +| `d` tag | `doc-chat:<docDTag>` — e.g. `doc-chat:1714310000` | +| `t` tag | `client-document-chat-v1` (distinct from ai.html's `client-ai-chat-v1`) | +| `content` | NIP-44-encrypted JSON `{ schema: 1, messages: [...] }` | + +Each message object shape: + +```json +{ + "id": "uuid-ish", + "role": "user" | "assistant", + "content": "text shown in bubble", + "documentSnapshot": "optional full markdown at time of assistant turn", + "created_at": 1714310015 +} +``` + +Storing the `documentSnapshot` with the assistant message is optional but cheap and gives us a natural undo/rollback surface later. + +**Load flow** (`loadDocChat(d)`): +1. Query cache for `{ kinds: [30078], authors: [self], '#d': ['doc-chat:' + d], '#t': ['client-document-chat-v1'] }`. +2. Decrypt `content` with NIP-44. +3. Parse `messages` array and render via the existing ai.html message thread renderer. +4. If no event exists, render empty thread. + +**Save flow** (`saveDocChat(d, messages)`): +1. Encrypt `{ schema: 1, messages }` with NIP-44 to self. +2. Publish kind 30078 with tags `[['d', 'doc-chat:' + d], ['t', 'client-document-chat-v1']]`. +3. Because addressable, this replaces the prior version on relays. + +**When messages are published**: after each completed assistant turn (chat appended + optional document replaced). User-only drafts in the input box are NOT published. + +**Empty-to-first-message transition**: if `CURRENT_NOTE` is a freshly-created doc that has never been saved, sending the first user message runs `Publish30024Note()` first (so the document `d` exists on relays) and then `saveDocChat()`, preventing orphan chats. + +**Deletion**: `DeleteNote(d)` publishes two kind-5 events — one for the document event id, one for the `doc-chat:<d>` event id. + +## 6. Publish-as-30023 action + +A "Publish as article" button in the document column header calls `Publish30023Note(CURRENT_NOTE)` (ported unchanged from note.html), with the same confirmation prompt. After publishing, the page reloads the sidenav list; the draft `d` tag is kept or cleaned up per note.html's existing logic. + +## 7. Files touched + +| File | Change | +|------|--------| +| [`www/document.html`](../www/document.html:1) | Major modification — new column, note model port, LLM JSON round-trip, dot-menu view toggle. | +| [`plans/document-page.md`](document-page.md:1) | This plan (new). | + +No new JS modules are required. Everything reuses existing modules: [`dot-menu.mjs`](../www/js/dot-menu.mjs:1), [`ai-chat.mjs`](../www/js/ai-chat.mjs:1), [`init-ndk.mjs`](../www/js/init-ndk.mjs:1), `marked`, `DOMPurify`. + +## 8. Open questions (non-blocking) + +- Should we show a visible diff when the LLM rewrites the document (red/green lines) before accepting? — Deferred; v1 just overwrites and relies on Nostr history for rollback. +- Should very large documents be chunked into the prompt? — Deferred; rely on `max_tokens` and surface a truncation warning (`sendAiChatJson` already returns `truncated`). +- Should we debounce-autosave every keystroke or only on blur? — Start with 1500 ms idle + blur; revisit if relay spam shows up. diff --git a/www/document.html b/www/document.html new file mode 100644 index 0000000..3f20b72 --- /dev/null +++ b/www/document.html @@ -0,0 +1,4124 @@ +<!DOCTYPE html> +<?xml version="1.0" encoding="UTF-8"?> +<html lang="en" dir="ltr"> + +<head> + <meta charset="utf-8" /> + <title>Document + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
Document
+
+
+
+ +
+
+
+
+
Documents
+
+ +
+ +
+
Skills
+
+ + +
+
+
+ + +
+
+
+ + +
+ + + +
+ +
+ Skills (0 selected) +
+
+ +
+
+ + +
+
+ +
+ +
+
+ + + +
+ +
+
No document selected.
+
+
+
+ +
+
+
+
+
0 sats
+
+ +
+
+ +
+ +
+
OpenAI-Compatible Config
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ +
+
+ +
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ +
+
Payment Methods
+ +
+
Balance
+ +
No balance loaded.
+
+ +
+
+ + +
+ +
+
No deposit invoice generated yet.
+
Idle.
+
+ +
+
+ + +
+
+ +
+
AI
+
+
No saved providers yet.
+
+
+ +
+
リレー
+
Loading relays...
+
+ +
+ +
ブロッサム
+ +
Loading blossom servers...
+ +
+ + +
+ v0.0.1 +
+ + +
+
+
+ + + + + + + + diff --git a/www/js/version.json b/www/js/version.json index 7eeba72..955467b 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.6", - "VERSION_NUMBER": "0.7.6", - "BUILD_DATE": "2026-04-21T10:32:47.088Z" + "VERSION": "v0.7.7", + "VERSION_NUMBER": "0.7.7", + "BUILD_DATE": "2026-04-28T14:00:02.302Z" }