SPB Git

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%
4.9 KB · 122 lines swift
Raw Blame History
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