diff --git a/CLAUDE.md b/CLAUDE.md index 61e9ceb..4be3b17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,11 +109,12 @@ pnpm test # Run tests | `VoiceLevel` | Voice-activity indicator shown inside the input while listening | | `MessageSpeechControls` | Read aloud / pause / resume / stop buttons under assistant replies | | `AttachmentPreview` | Image thumbnail / file chip for pending and sent attachments | +| `HeaderMenu` | Generic accessible overflow menu in the chat header; hosts the export actions | ### useChat Hook Manages chat state: -- `messages` - Array of chat messages +- `messages` - Array of chat messages, each stamped with an ISO `createdAt` when it enters the conversation - `isLoading` - Loading state during API calls - `isStreaming` - True while an assistant reply is streaming in - `streamingMessageId` - Id of the message currently receiving tokens @@ -195,6 +196,28 @@ the worker, but browsers recognize speech on their vendor's servers by default. Do not describe it as on-device. Docs: configuration/voice.md; design: docs/plans/2026-09-18-voice-input-tts-design.md. +### Conversation Export + +Widget-only and opt-in (`conversationExport` prop / `ClaudiusConfig` key / +`` / `widget.conversationExport` in client +configs). It fails closed: only the literal `true` enables it, and nothing +renders when it is off. A `HeaderMenu` in the chat header offers Copy as +Markdown, Download as Markdown, and Download as JSON, all in the browser with +no request to the worker. + +Pure serializers live in `widget/src/utils/exportConversation.ts` +(`protectMessageText`, `conversationToMarkdown`, `conversationToJson`, +`exportFilename`); clipboard and download side effects in +`widget/src/utils/saveText.ts`; `useConversationExport` (owned by `ChatWindow`) +turns messages into menu items plus a transient status. Markdown keeps message +text as written but closes unclosed code fences, turns single newlines into +hard breaks outside code, and escapes a line-leading `<`. JSON is the +persisted `ChatMessage[]`, never with inline attachment bytes. + +Never assert on raw `Intl` output in tests: CI's Node 20 emits U+202F before +"PM" and local Node 24 does not. Docs: configuration/conversation-export.md; +design: docs/plans/2026-09-19-conversation-export-design.md. + ### Chat Request/Response ```typescript diff --git a/clients/_schema.json b/clients/_schema.json index 8e76e01..8c13213 100644 --- a/clients/_schema.json +++ b/clients/_schema.json @@ -79,6 +79,10 @@ } } ] + }, + "conversationExport": { + "type": "boolean", + "description": "Let visitors copy the conversation as Markdown, or download it as Markdown or JSON, from a menu in the chat header. Off by default. Nothing is sent to the worker." } } }, diff --git a/docs/plans/2026-09-19-conversation-export-design.md b/docs/plans/2026-09-19-conversation-export-design.md new file mode 100644 index 0000000..f471e01 --- /dev/null +++ b/docs/plans/2026-09-19-conversation-export-design.md @@ -0,0 +1,465 @@ +# Conversation export: design + +Issue: [#55](https://github.com/PMDevSolutions/Claudius/issues/55) +Date: 2026-09-19 +Status: approved, implemented in the same PR as this document + +## Summary + +Add an opt-in `conversationExport` option to the widget. It puts an overflow +menu in the chat header with three actions: copy the conversation to the +clipboard as Markdown, download it as a Markdown file, and download it as a +JSON file. Everything happens in the browser. Nothing is sent to the worker. + +Exporting timestamps requires recording them, so `ChatMessage` gains an +optional `createdAt`. That is the only change outside the new feature. There +is no worker, API, or wire-format change. + +## Facts that constrain the design + +These were checked against the code, MDN, and a local Node 24 (ICU 78.3) on +2026-09-19. Several shaped the design more than the issue text did. + +1. **Messages carry no timestamp.** `ChatMessage` has `id`, `role`, `content`, + `sources`, `attachments`, and `toolUses`. The issue requires timestamps in + the Markdown export, so they have to be recorded first. +2. **The worker ignores unknown message fields, and always has.** Today + `validateMessages` returns `{ role, content, attachments? }`. The very + first version of `worker/src/chat.ts` (b53d64a) already mapped each message + to `{ role, content }`. The widget has always sent `id`, and later + `sources` and `toolUses`, which every deployed worker discards. A new + optional field is therefore safe even for a worker that is never + redeployed. +3. **Embeds load a floating tag.** The quick start documents + `cdn.jsdelivr.net/gh/PMDevSolutions/Claudius@1/cdn/claudius.iife.js`. A + minor release reaches every live site with no action by its owner, so a + feature that is on by default appears everywhere at once. +4. **The widget renders very little Markdown.** `ChatMessage` handles bold, + italic, and URLs, and turns every newline into a `
`. Everything else, + code fences included, is shown as literal text. So "preserves code blocks" + cannot mean converting anything: it means not damaging text that is + already Markdown. It also means a single newline is a visible line break + on screen, while CommonMark treats it as a space. The default system + prompt tells the bot to "ALWAYS use line breaks between sentences", so + this is the normal case, not an edge case. +5. **Partial replies are kept.** When a visitor stops a stream, or a stream + breaks after text has rendered, `useChat` keeps the partial reply as a + settled message. A reply cut off inside a code block leaves an unclosed + fence in the history. +6. **Escape closes the chat from a document-level listener** + (`ChatWindow`), and the focus trap treats every enabled `button` as + focusable, whatever its `tabindex`. +7. **Inline attachment bytes exist only in memory.** A `ChatAttachment` holds + base64 `data` until the worker stores the file. `stripAttachmentData` + removes it before anything is persisted. +8. **Clipboard writes are restricted.** MDN: writing "can only be done in a + secure context"; in Firefox and Safari "writing requires transient + activation"; and `clipboard-write` "must be allowed for `