diff --git a/fullmoon.xcodeproj/project.pbxproj b/fullmoon.xcodeproj/project.pbxproj index 1d46d74..6c01007 100644 --- a/fullmoon.xcodeproj/project.pbxproj +++ b/fullmoon.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0033E7FF2D4718D9001D469E /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 0033E7FE2D4718D9001D469E /* Highlightr */; }; 860F26A42CBC31D6004E8D40 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 860F26A32CBC31D6004E8D40 /* MarkdownUI */; }; 869B97622D0DD46B0078DF5A /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 869B97612D0DD46B0078DF5A /* MLXLMCommon */; }; 869B97642D0DD4D80078DF5A /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = 869B97632D0DD4D80078DF5A /* MLXLLM */; }; @@ -42,6 +43,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0033E7FF2D4718D9001D469E /* Highlightr in Frameworks */, 869B97642D0DD4D80078DF5A /* MLXLLM in Frameworks */, 869B97622D0DD46B0078DF5A /* MLXLMCommon in Frameworks */, 860F26A42CBC31D6004E8D40 /* MarkdownUI in Frameworks */, @@ -98,6 +100,7 @@ 860F26A32CBC31D6004E8D40 /* MarkdownUI */, 869B97612D0DD46B0078DF5A /* MLXLMCommon */, 869B97632D0DD4D80078DF5A /* MLXLLM */, + 0033E7FE2D4718D9001D469E /* Highlightr */, ); productName = fullmoon; productReference = 860E9CCE2CB055B000C5BB52 /* fullmoon.app */; @@ -130,6 +133,7 @@ packageReferences = ( 860E9CE22CB0564600C5BB52 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */, 860F26A22CBC31D6004E8D40 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, + 0033E7FD2D4718D9001D469E /* XCRemoteSwiftPackageReference "Highlightr" */, ); preferredProjectObjectVersion = 77; productRefGroup = 860E9CCF2CB055B000C5BB52 /* Products */; @@ -285,7 +289,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"fullmoon/Preview Content\""; - DEVELOPMENT_TEAM = 2VT466P8NK; + DEVELOPMENT_TEAM = MMRT976ZJS; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -307,7 +311,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = me.mainfra.fullmoon; + PRODUCT_BUNDLE_IDENTIFIER = me.bleu.moon; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = NO; SDKROOT = auto; @@ -331,7 +335,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"fullmoon/Preview Content\""; - DEVELOPMENT_TEAM = 2VT466P8NK; + DEVELOPMENT_TEAM = MMRT976ZJS; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -353,7 +357,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = me.mainfra.fullmoon; + PRODUCT_BUNDLE_IDENTIFIER = me.bleu.moon; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = NO; SDKROOT = auto; @@ -392,6 +396,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 0033E7FD2D4718D9001D469E /* XCRemoteSwiftPackageReference "Highlightr" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/raspu/Highlightr.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.2.1; + }; + }; 860E9CE22CB0564600C5BB52 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ml-explore/mlx-swift-examples/"; @@ -411,6 +423,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0033E7FE2D4718D9001D469E /* Highlightr */ = { + isa = XCSwiftPackageProductDependency; + package = 0033E7FD2D4718D9001D469E /* XCRemoteSwiftPackageReference "Highlightr" */; + productName = Highlightr; + }; 860F26A32CBC31D6004E8D40 /* MarkdownUI */ = { isa = XCSwiftPackageProductDependency; package = 860F26A22CBC31D6004E8D40 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; diff --git a/fullmoon/Models/Data.swift b/fullmoon/Models/Data.swift index 8f73b45..f0ba102 100644 --- a/fullmoon/Models/Data.swift +++ b/fullmoon/Models/Data.swift @@ -153,23 +153,24 @@ class Message { } @Model -final class Thread: Sendable { - @Attribute(.unique) var id: UUID - var title: String? +final class Thread { + @Attribute(.unique) let id: UUID var timestamp: Date - - @Relationship var messages: [Message] = [] - - var sortedMessages: [Message] { - return messages.sorted { $0.timestamp < $1.timestamp } - } + var messages: [Message] init() { self.id = UUID() self.timestamp = Date() + self.messages = [] + } + + var sortedMessages: [Message] { + messages.sorted { $0.timestamp < $1.timestamp } } } +extension Thread: @unchecked Sendable {} + enum AppTintColor: String, CaseIterable { case monochrome, blue, brown, gray, green, indigo, mint, orange, pink, purple, red, teal, yellow diff --git a/fullmoon/Views/Chat/CodeBlockView.swift b/fullmoon/Views/Chat/CodeBlockView.swift new file mode 100644 index 0000000..093860f --- /dev/null +++ b/fullmoon/Views/Chat/CodeBlockView.swift @@ -0,0 +1,132 @@ +import SwiftUI +import MarkdownUI +import Highlightr + +private let languageMap: [String: String] = [ + "js": "javascript", + "ts": "typescript", + "py": "python", + "rb": "ruby", + "shell": "bash", + "sh": "bash", + "swift: "swift", + "jsx": "javascript", + "tsx": "typescript", + "yml": "yaml", + "md": "markdown", + "cpp": "c++", + "objective-c": "objectivec", + "objc": "objectivec", + "golang": "go" +] + +struct CodeBlockView: View { + let code: String + let language: String? + @Environment(\.colorScheme) var colorScheme + @State private var isCopied = false + + private let highlightr = Highlightr() + + var platformBackgroundColor: Color { + #if os(iOS) + return Color(UIColor.secondarySystemBackground) + #elseif os(visionOS) + return Color(UIColor.separator) + #elseif os(macOS) + return Color(NSColor.secondarySystemFill) + #endif + } + + private func normalizeLanguage(_ language: String?) -> String? { + guard let language = language?.lowercased() else { return nil } + return languageMap[language] ?? language + } + + var highlightedCode: NSAttributedString? { + guard let highlightr = highlightr else { return nil } + highlightr.setTheme(to: colorScheme == .dark ? "atom-one-dark" : "atom-one-light") + highlightr.theme.codeFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + return highlightr.highlight(code, as: normalizeLanguage(language)) + } + + func copyToClipboard() { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + #else + UIPasteboard.general.string = code + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + #endif + + withAnimation { + isCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + isCopied = false + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + if let language { + Text(language) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(action: copyToClipboard) { + HStack(spacing: 4) { + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 12)) + Text(isCopied ? "Copied!" : "Copy") + .font(.caption) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(.secondary.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + .padding(.bottom, 2) + + ScrollView(.horizontal, showsIndicators: false) { + if let highlightedCode { + Text(AttributedString(highlightedCode)) + .textSelection(.enabled) + } else { + Text(code) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + } + .padding() + .background(platformBackgroundColor) + .cornerRadius(8) + } +} + +#Preview { + VStack(spacing: 20) { + CodeBlockView( + code: "print(\"Hello, World!\")", + language: "swift" + ) + CodeBlockView( + code: "function hello() {\n console.log('Hello World');\n}", + language: "js" + ) + CodeBlockView( + code: "def hello():\n print('Hello World')", + language: "python" + ) + } + .padding() +} diff --git a/fullmoon/Views/Chat/ConversationView.swift b/fullmoon/Views/Chat/ConversationView.swift index 7900f09..61f0123 100644 --- a/fullmoon/Views/Chat/ConversationView.swift +++ b/fullmoon/Views/Chat/ConversationView.swift @@ -5,8 +5,9 @@ // Created by Xavier on 16/12/2024. // -import MarkdownUI import SwiftUI +import SwiftData +import MarkdownUI extension TimeInterval { var formatted: String { @@ -82,6 +83,26 @@ struct MessageView: View { .foregroundStyle(.secondary) } + private func parseCodeBlocks(_ text: String) -> AttributedString { + var config = AttributedString.MarkdownParsingOptions() + config.interpretedSyntax = .inlineOnlyPreservingWhitespace + return (try? AttributedString(markdown: text, options: config)) ?? AttributedString(text) + } + + private func markdownTheme(foregroundColor: Color) -> Theme { + Theme() + .text { + ForegroundColor(foregroundColor) + } + .code { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .codeBlock { content in + CodeBlockView(code: content.content, language: content.language) + } + } + var body: some View { HStack { if message.role == .user { Spacer() } @@ -101,9 +122,7 @@ struct MessageView: View { .foregroundStyle(.fill) Markdown(thinking) .textSelection(.enabled) - .markdownTextStyle { - ForegroundColor(.secondary) - } + .markdownTheme(markdownTheme(foregroundColor: .secondary)) } .padding(.leading, 5) } @@ -121,12 +140,16 @@ struct MessageView: View { if let afterThink { Markdown(afterThink) .textSelection(.enabled) + .markdownTheme(markdownTheme(foregroundColor: .primary)) } } .padding(.trailing, 48) } else { - Markdown(message.content) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: 8) { + Markdown(message.content) + .textSelection(.enabled) + .markdownTheme(markdownTheme(foregroundColor: .primary)) + } #if os(iOS) || os(visionOS) .padding(.horizontal, 16) .padding(.vertical, 12)