/** * Browser-side Markdown export utilities. * * Generates downloadable `.md` files from conversations and saved responses. * All file generation and download triggering happens entirely in the browser * without a server round-trip (Requirement 11.4). * * Output format: * --- * date: YYYY-MM-DD * model: claude-3-5-sonnet * type: conversation | response * --- * * # Question text * * Answer body * * ## Sources * * 1. [Title](url) — domain * 2. Title — domain */ import type { Conversation, QueryResult, SavedResponse } from "../types"; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Generates a Markdown export of a full conversation and triggers a browser * file download. * * The filename follows the pattern: `health-marketing-{YYYYMMDD}-{slug}.md` * where `slug` is derived from the first user question. * * Requirements: 11.1, 11.3 */ export function exportConversation(conversation: Conversation): void { const today = formatDate(new Date()); const frontMatter = buildFrontMatter(today, "conversation"); const sections: string[] = [frontMatter]; // Iterate over message pairs: user message followed by assistant message. const { messages, results } = conversation; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (msg.role !== "user") continue; const question = msg.content; const assistantMsg = messages[i + 1]; const answer = assistantMsg && assistantMsg.role === "assistant" ? assistantMsg.content : ""; // results are keyed by the assistant message index (as a string). const resultKey = String(i + 1); const result = results[resultKey] ?? null; sections.push(buildQASection(question, answer, result)); } const markdown = sections.join("\n\n"); const firstQuestion = messages.find((m) => m.role === "user")?.content ?? ""; const slug = slugify(firstQuestion); const filename = `health-marketing-${today.replace(/-/g, "")}-${slug}.md`; triggerDownload(markdown, filename); } /** * Generates a Markdown export of a single Q&A pair and triggers a browser * file download. * * Requirements: 11.2, 11.3 */ export function exportSingleResponse( question: string, answer: string, result: QueryResult ): void { const today = formatDate(new Date()); const frontMatter = buildFrontMatter(today, "response"); const qaSection = buildQASection(question, answer, result); const markdown = `${frontMatter}\n\n${qaSection}`; const slug = slugify(question); const filename = `health-marketing-${today.replace(/-/g, "")}-${slug}.md`; triggerDownload(markdown, filename); } /** * Generates a Markdown export of all saved responses concatenated into a * single file, separated by `---` dividers, and triggers a browser file * download. * * Requirements: 11.1, 11.3 */ export function exportAllSavedResponses(saved: SavedResponse[]): void { const today = formatDate(new Date()); const frontMatter = buildFrontMatter(today, "saved-responses"); const responseSections = saved.map((sr) => buildQASection(sr.question, sr.answer, sr.result) ); const markdown = frontMatter + "\n\n" + responseSections.join("\n\n---\n\n"); const filename = `health-marketing-${today.replace(/-/g, "")}-saved-responses.md`; triggerDownload(markdown, filename); } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Formats a Date as `YYYY-MM-DD` (local date, not UTC). */ function formatDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } /** * Builds the YAML front-matter block. * * Example: * --- * date: 2024-01-15 * model: claude-3-5-sonnet * type: conversation * --- */ function buildFrontMatter(date: string, type: string): string { return `---\ndate: ${date}\nmodel: claude-3-5-sonnet\ntype: ${type}\n---`; } /** * Builds a single Q&A section: H1 question, answer body, and Sources section. * * Example: * # What are the advertising rules for chiropractors? * * [answer body here] * * ## Sources * * 1. [Section Title](https://example.com) — domain * 2. Section Title — domain */ function buildQASection( question: string, answer: string, result: QueryResult | null ): string { const parts: string[] = []; parts.push(`# ${question}`); parts.push(answer); if (result && result.citations.length > 0) { const sourceLines = result.citations.map((citation) => { const prefix = `${citation.index}.`; const titlePart = citation.source_url ? `[${citation.title}](${citation.source_url})` : citation.title; return `${prefix} ${titlePart} — ${citation.domain}`; }); parts.push(`## Sources\n\n${sourceLines.join("\n")}`); } return parts.join("\n\n"); } /** * Converts a question string into a URL-safe slug for use in filenames. * * - Lowercases the string * - Replaces spaces with hyphens * - Removes non-alphanumeric characters (except hyphens) * - Truncates to 40 characters * - Trims trailing hyphens */ function slugify(text: string): string { return text .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, "") .slice(0, 40) .replace(/-+$/, ""); } /** * Triggers a browser file download using the Blob + URL.createObjectURL * + anchor click pattern. */ function triggerDownload(content: string, filename: string): void { const blob = new Blob([content], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.style.display = "none"; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); // Release the object URL to free memory. URL.revokeObjectURL(url); }