Files
oai-swift/oAI/oAIApp.swift
T
rune 634b83f284 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.
2026-07-29 07:47:57 +02:00

198 lines
8.7 KiB
Swift

//
// oAIApp.swift
// oAI
//
// Main app entry point
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import SwiftUI
#if os(macOS)
import AppKit
#endif
@main
struct oAIApp: App {
@State private var chatViewModel = ChatViewModel()
@State private var showAbout = false
init() {
// Start email handler on app launch
EmailHandlerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
// Sync Git changes on app launch (pull + import)
Task {
await GitSyncService.shared.syncOnStartup()
}
// Reconcile starred models with the iCloud copy (cross-machine favorites sync)
Task {
await BackupService.shared.syncFavoritesOnLaunch()
}
// Check for updates in the background
UpdateCheckService.shared.checkForUpdates()
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(chatViewModel)
.preferredColorScheme(.dark)
.sheet(isPresented: $showAbout) {
AboutView()
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
await BackupService.shared.syncFavoritesOnLaunch()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
Task {
await chatViewModel.onAppWillTerminate()
}
}
#endif
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.defaultSize(width: 1024, height: 800)
.windowResizability(.contentMinSize)
.commands {
// ── Apple menu ────────────────────────────────────────────────
CommandGroup(replacing: .appInfo) {
Button("About oAI") { showAbout = true }
}
CommandGroup(replacing: .appSettings) {
Button("Settings…") { chatViewModel.showSettings = true }
.keyboardShortcut(",", modifiers: .command)
}
// ── File menu ─────────────────────────────────────────────────
// Replacing .newItem removes the auto-added "New Window" entry
CommandGroup(replacing: .newItem) {
Button("New Chat") { chatViewModel.newConversation() }
.keyboardShortcut("n", modifiers: .command)
Button("Clear Chat") { chatViewModel.clearChat() }
.keyboardShortcut("k", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command)
Button("Search Conversations") { chatViewModel.showConversations = true }
.keyboardShortcut("l", modifiers: .command)
}
CommandGroup(replacing: .saveItem) {
Button("Save Chat") { chatViewModel.saveFromMenu() }
.keyboardShortcut("s", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Save Chat As…") { chatViewModel.saveAsFromMenu() }
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Stats") { chatViewModel.showStats = true }
.keyboardShortcut("s", modifiers: [.command, .shift])
}
CommandGroup(after: .importExport) {
Button("Export as Markdown…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversation(format: "md", filename: "\(safe).md")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as HTML…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversation(format: "html", filename: "\(safe).html")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as PDF…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversation(format: "pdf", filename: "\(safe).pdf")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
// ── Chat menu ─────────────────────────────────────────────────
// Named "Chat" (not "View") to avoid colliding with the "View" menu
// macOS auto-adds for NavigationSplitView (Enter Full Screen, etc.) —
// two menus both titled "View" would otherwise appear in the menu bar.
CommandMenu("Chat") {
Button("Select Model") { chatViewModel.showModelSelector = true }
.keyboardShortcut("m", modifiers: .command)
Button("Model Info") {
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
}
.keyboardShortcut("i", modifiers: .command)
.disabled(chatViewModel.selectedModel == nil)
Divider()
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: [.command, .shift])
Button("Credits") { chatViewModel.showCredits = true }
Divider()
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
chatViewModel.onlineMode.toggle()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
// ── Help menu ─────────────────────────────────────────────────
CommandGroup(replacing: .help) {
Button("oAI Help") { openHelp() }
.keyboardShortcut("?", modifiers: .command)
Divider()
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}
.disabled(UpdateCheckService.shared.isCheckingManually)
}
}
#endif
}
#if os(macOS)
private func openHelp() {
// Opens the Help Book's index.html directly in the default browser rather than
// through NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why
// (Apple's Tips.app replacement for Help Viewer can't resolve anchors on this beta).
// Revisit once macOS 27 reaches RC.
if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) {
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
}
}
#endif
}