Add HTML and PDF conversation export

Top item on the roadmap ranking from 2026-07-27 — multi-modal export
alongside the existing Markdown/JSON. New ConversationExportService
consolidates the two previously-duplicated Markdown builders
(ChatViewModel and ConversationListView had separate copies of the
same **User**/**Assistant** + --- format) and adds:

- A hand-rolled Markdown->HTML renderer scoped to what actually shows
  up in chat messages (headers, bold/italic, inline code, fenced code
  blocks, lists, blockquotes, links, horizontal rules) rather than
  full CommonMark/GFM — no existing markdown-to-HTML utility existed
  in the codebase, and swift-markdown-ui is SwiftUI-view-only with no
  HTML-string export API. Content is HTML-escaped before any markdown
  substitution so example code containing "<div>" etc renders as
  visible text, not live markup.
- PDF via an offscreen WKWebView loading that same HTML and calling
  the official createPDF(configuration:) API (macOS 11+) — no new
  project/framework linkage needed, WebKit is a system framework.

Wired into every place Markdown export already existed: File menu
(Export as HTML.../PDF...), /export slash command (now md|html|pdf|json),
and a new Export submenu (Markdown/HTML/PDF) on each conversation row's
context menu in the advanced conversation list, replacing the old
single-format swipe-only export. Help docs and InputBar autocomplete
updated to match.
This commit is contained in:
2026-07-29 07:47:57 +02:00
parent 727fc7d6af
commit 634b83f284
8 changed files with 562 additions and 34 deletions
+26 -16
View File
@@ -727,7 +727,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
exportConversation(format: format, filename: filename)
} else {
showSystemMessage("Usage: /export md|json <filename>")
showSystemMessage("Usage: /export md|html|pdf|json <filename>")
}
case "/info":
@@ -1769,13 +1769,29 @@ Don't narrate future actions ("Let me...") - just use the tools.
return
}
if format == "pdf" {
let name = currentConversationName ?? "conversation"
Task { @MainActor in
do {
let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
if let url = ConversationExportService.writeToDownloads(data, filename: filename) {
showSystemMessage("Exported to \(url.path)")
} else {
showSystemMessage("Export failed: could not write file")
}
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
}
}
return
}
let content: String
switch format {
case "md", "markdown":
content = chatMessages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
content = ConversationExportService.markdown(messages: chatMessages)
case "html":
content = ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
case "json":
let dicts = chatMessages.map { msg -> [String: String] in
["role": msg.role.rawValue, "content": msg.content]
@@ -1788,20 +1804,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
return
}
default:
showSystemMessage("Unsupported format: \(format). Use md or json.")
showSystemMessage("Unsupported format: \(format). Use md, html, pdf, or json.")
return
}
// Write to Downloads folder
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let fileURL = downloads.appendingPathComponent(filename)
do {
try content.write(to: fileURL, atomically: true, encoding: .utf8)
showSystemMessage("Exported to \(fileURL.path)")
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
if let url = ConversationExportService.writeToDownloads(content, filename: filename) {
showSystemMessage("Exported to \(url.path)")
} else {
showSystemMessage("Export failed: could not write file")
}
}