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 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); 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
#divAiLayoutto 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:
#divAiDocumentPanecontaining:- Header row with title input, publish-as-30023 button, and a
.doc-menu-hostfor the dot-menu. - Summary input + hashtags input (single line each).
- Image URL input (optional).
#taDocumentmarkdown textarea (main editor).#divDocumentPreviewhidden div for rendered-markdown view mode.- Small status line ("Saved / UNSAVED", current kind, current
dtag).
- Header row with title input, publish-as-30023 button, and a
- Left:
- 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:
#divAiDocumentPaneisflex: 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_NOTESmap keyed bydtag.renderDocumentsList()— rendersOBJ_NOTESas 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, setsCURRENT_NOTE = d, re-renders preview viamarked, AND triggersloadDocChat(d)(see §5.3).newDocument()— assignsCURRENT_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 paireddoc-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:
LastTextbaseline 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 samedtag.
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
#divDocumentPreviewwithmarked.parse(taDocument.value)(purified viaDOMPurifyalready 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):
- Build
combinedSystemfrom selected skills as today, then appendDOCUMENT_EDITOR_WRAPPER's system portion. - Resolve
{{document}}placeholder withtaDocument.valueand{{message}}with the user's input. - Call the chat endpoint using
sendAiChatJson(already supportsresponse_format: { type: 'json_object' }) — replaces the current plaincallOpenAICompatibleChatpath only on this page. - On success,
result.jsonis{ chat, document }:- Append
chatas the assistant message in the thread. - If
documentis a non-empty string and differs fromtaDocument.value, replace the textarea, re-render preview if in markdown view, trigger immediate autosave. - If
documentisnull/omitted, skip the document update (chat-only turn).
- Append
- On malformed JSON, fall back to displaying
result.contentas 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)):
- Query cache for
{ kinds: [30078], authors: [self], '#d': ['doc-chat:' + d], '#t': ['client-document-chat-v1'] }. - Decrypt
contentwith NIP-44. - Parse
messagesarray and render via the existing ai.html message thread renderer. - If no event exists, render empty thread.
Save flow (saveDocChat(d, messages)):
- Encrypt
{ schema: 1, messages }with NIP-44 to self. - Publish kind 30078 with tags
[['d', 'doc-chat:' + d], ['t', 'client-document-chat-v1']]. - 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_tokensand surface a truncation warning (sendAiChatJsonalready returnstruncated). - Should we debounce-autosave every keystroke or only on blur? — Start with 1500 ms idle + blur; revisit if relay spam shows up.