Files
client/plans/document-page.md

11 KiB

Document Page (www/document.html)

Overview

A new page, derived from www/ai.html, 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:

flowchart LR
    A[Documents + Skills<br/>left pane] --> B[Chat thread<br/>center pane]
    B --> C[Document column<br/>title + markdown editor<br/>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 Aresponse_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); both the user and the LLM can write to it.
View toggle Reuse dot-menu: "View as markdown", "View as plaintext", "Copy document".

1. HTML structure changes

Starting from the current copy of www/ai.html at www/document.html:

  • Change <title> 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 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 on .doc-menu-host:

┌───────── 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.

5. LLM ↔ document wiring

5.1 Hardcoded skill wrapper

Add a constant inside document.html:

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).

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 (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:

{
  "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 Major modification — new column, note model port, LLM JSON round-trip, dot-menu view toggle.
plans/document-page.md This plan (new).

No new JS modules are required. Everything reuses existing modules: dot-menu.mjs, ai-chat.mjs, init-ndk.mjs, 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.