spb/zyquo-local Public MIT
Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.
Swift 97.2%
Shell 1.8%
Makefile 1%
1//2// ExportService.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import AppKit10import Foundation11import UniformTypeIdentifiers1213/// Conversation export: Markdown and PDF via save panels.14@MainActor15enum ExportService {16 static func markdown(for conversation: Conversation) -> String {17 var lines: [String] = []18 lines.append("# \(conversation.title)")19 lines.append("")20 lines.append("_Exported from Zyquo Local · \(conversation.updatedAt.formatted(date: .abbreviated, time: .shortened))_")21 if let modelID = conversation.modelID {22 lines.append("_Model: \(modelID)_")23 }24 lines.append("")25 if let system = conversation.systemPrompt, !system.isEmpty {26 lines.append("> **System:** \(system)")27 lines.append("")28 }29 for message in conversation.messages where message.role != .system {30 lines.append(message.role == .user ? "## You" : "## Assistant")31 lines.append("")32 if let thinking = message.thinking, !thinking.isEmpty {33 lines.append("<details><summary>Thinking</summary>")34 lines.append("")35 lines.append(thinking)36 lines.append("")37 lines.append("</details>")38 lines.append("")39 }40 lines.append(message.content)41 if let stats = message.stats {42 lines.append("")43 lines.append(String(44 format: "_%.1f tok/s · %d tokens · %.1fs to first token_",45 stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken))46 }47 lines.append("")48 }49 return lines.joined(separator: "\n")50 }5152 static func exportMarkdown(_ conversation: Conversation) {53 let panel = NSSavePanel()54 panel.allowedContentTypes = [.init(filenameExtension: "md") ?? .plainText]55 panel.nameFieldStringValue = sanitizedFileName(conversation.title) + ".md"56 guard panel.runModal() == .OK, let url = panel.url else { return }57 try? markdown(for: conversation).write(to: url, atomically: true, encoding: .utf8)58 }5960 static func exportPDF(_ conversation: Conversation) {61 let panel = NSSavePanel()62 panel.allowedContentTypes = [.pdf]63 panel.nameFieldStringValue = sanitizedFileName(conversation.title) + ".pdf"64 guard panel.runModal() == .OK, let url = panel.url else { return }65 writePDF(conversation, to: url)66 }6768 /// Simple paginated PDF from an attributed rendition of the transcript.69 private static func writePDF(_ conversation: Conversation, to url: URL) {70 let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter71 let inset: CGFloat = 5472 let contentRect = pageRect.insetBy(dx: inset, dy: inset)7374 let text = NSMutableAttributedString()75 let titleFont = NSFont.systemFont(ofSize: 18, weight: .semibold)76 let bodyFont = NSFont.systemFont(ofSize: 11)77 let roleFont = NSFont.systemFont(ofSize: 11, weight: .semibold)78 let metaFont = NSFont.systemFont(ofSize: 9)7980 text.append(NSAttributedString(81 string: conversation.title + "\n",82 attributes: [.font: titleFont]))83 text.append(NSAttributedString(84 string: "Exported from Zyquo Local · \(conversation.updatedAt.formatted())\n\n",85 attributes: [.font: metaFont, .foregroundColor: NSColor.secondaryLabelColor]))8687 for message in conversation.messages where message.role != .system {88 text.append(NSAttributedString(89 string: (message.role == .user ? "You" : "Assistant") + "\n",90 attributes: [.font: roleFont]))91 text.append(NSAttributedString(92 string: message.content + "\n\n",93 attributes: [.font: bodyFont]))94 }9596 var mediaBox = pageRect97 guard let consumer = CGDataConsumer(url: url as CFURL),98 let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil)99 else { return }100101 let framesetter = CTFramesetterCreateWithAttributedString(text)102 var location = 0103 while location < text.length {104 context.beginPDFPage(nil)105 let path = CGPath(rect: contentRect, transform: nil)106 let frame = CTFramesetterCreateFrame(107 framesetter, CFRange(location: location, length: 0), path, nil)108 CTFrameDraw(frame, context)109 let visible = CTFrameGetVisibleStringRange(frame)110 location += max(visible.length, 1)111 context.endPDFPage()112 }113 context.closePDF()114 }115116 private static func sanitizedFileName(_ name: String) -> String {117 let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>")118 let cleaned = name.components(separatedBy: invalid).joined(separator: "-")119 return cleaned.isEmpty ? "Conversation" : cleaned120 }121}122