Add vision support, gRPC reflection toggle, and chat improvements

- Add Vision framework integration for image analysis (OCR, classification)
- Add image attachment support in chat UI with drag & drop
- Add recent images sidebar from Downloads/Desktop
- Add copy to clipboard button for assistant responses
- Add gRPC reflection service with toggle in settings
- Create proper .proto file and generate Swift code
- Add server restart when toggling reflection setting
- Fix port number formatting in settings (remove comma grouping)
- Update gRPC dependencies to v2.x

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mathias Beaulieu-Duncan
2025-12-30 16:18:06 -05:00
parent 62ab635aec
commit 638656e7ca
18 changed files with 2474 additions and 478 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ struct AppleIntelligenceApp: App {
.defaultSize(width: 500, height: 600)
Window("Settings", id: "settings") {
SettingsView(settings: settings)
SettingsView(settings: settings, serverManager: serverManager)
}
.windowResizability(.contentSize)
}
@@ -19,6 +19,10 @@ final class AppSettings {
didSet { UserDefaults.standard.set(autoStartServer, forKey: "auto_start_server") }
}
var enableReflection: Bool {
didSet { UserDefaults.standard.set(enableReflection, forKey: "enable_reflection") }
}
var launchAtLogin: Bool {
didSet {
do {
@@ -39,6 +43,12 @@ final class AppSettings {
self.port = savedPort == 0 ? 50051 : savedPort
self.apiKey = UserDefaults.standard.string(forKey: "api_key") ?? ""
self.autoStartServer = UserDefaults.standard.bool(forKey: "auto_start_server")
// Default to true if not set
if UserDefaults.standard.object(forKey: "enable_reflection") == nil {
self.enableReflection = true
} else {
self.enableReflection = UserDefaults.standard.bool(forKey: "enable_reflection")
}
self.launchAtLogin = SMAppService.mainApp.status == .enabled
}
@@ -47,6 +57,7 @@ final class AppSettings {
port = 50051
apiKey = ""
autoStartServer = false
enableReflection = true
launchAtLogin = false
}
}
@@ -1,4 +1,62 @@
import Foundation
import AppKit
/// Represents an attached image in a chat message
struct ImageAttachment: Identifiable, Equatable {
let id: UUID
let data: Data
let filename: String?
let thumbnail: NSImage?
let mimeType: String
init(data: Data, filename: String? = nil) {
self.id = UUID()
self.data = data
self.filename = filename
self.thumbnail = Self.generateThumbnail(from: data)
self.mimeType = Self.detectMimeType(from: data)
}
private static func generateThumbnail(from data: Data) -> NSImage? {
guard let image = NSImage(data: data) else { return nil }
let maxSize: CGFloat = 100
let ratio = min(maxSize / image.size.width, maxSize / image.size.height, 1.0)
let newSize = NSSize(
width: image.size.width * ratio,
height: image.size.height * ratio
)
let thumbnail = NSImage(size: newSize)
thumbnail.lockFocus()
image.draw(
in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: image.size),
operation: .copy,
fraction: 1.0
)
thumbnail.unlockFocus()
return thumbnail
}
private static func detectMimeType(from data: Data) -> String {
guard data.count >= 4 else { return "application/octet-stream" }
let bytes = [UInt8](data.prefix(4))
if bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 {
return "image/png"
} else if bytes[0] == 0xFF && bytes[1] == 0xD8 {
return "image/jpeg"
} else if bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 {
return "image/gif"
}
return "image/png" // Default to PNG
}
static func == (lhs: ImageAttachment, rhs: ImageAttachment) -> Bool {
lhs.id == rhs.id
}
}
struct ChatMessage: Identifiable, Equatable {
let id: UUID
@@ -6,17 +64,19 @@ struct ChatMessage: Identifiable, Equatable {
var content: String
let timestamp: Date
var isStreaming: Bool
var images: [ImageAttachment]
enum Role: Equatable {
case user
case assistant
}
init(role: Role, content: String, isStreaming: Bool = false) {
init(role: Role, content: String, isStreaming: Bool = false, images: [ImageAttachment] = []) {
self.id = UUID()
self.role = role
self.content = content
self.timestamp = Date()
self.isStreaming = isStreaming
self.images = images
}
}
@@ -2,6 +2,7 @@ import Foundation
import AppleIntelligenceCore
import GRPCCore
import GRPCNIOTransportHTTP2
import GRPCReflectionService
@MainActor
@Observable
@@ -51,6 +52,7 @@ final class ServerManager {
let host = settings.host
let port = settings.port
let apiKey = settings.apiKey.isEmpty ? nil : settings.apiKey
let enableReflection = settings.enableReflection
serverTask = Task {
do {
@@ -82,7 +84,16 @@ final class ServerManager {
config: .defaults
)
let server = GRPCServer(transport: transport, services: [provider])
// Build services list with optional reflection
var services: [any RegistrableRPCService] = [provider]
if enableReflection {
if let descriptorURL = AppleIntelligenceResources.descriptorSetURL {
let reflectionService = try ReflectionService(descriptorSetFileURLs: [descriptorURL])
services.append(reflectionService)
}
}
let server = GRPCServer(transport: transport, services: services)
await MainActor.run {
self.state = .running(host: host, port: port)
@@ -113,6 +124,19 @@ final class ServerManager {
state = .stopped
}
func restart() {
guard state.isRunning else { return }
// Stop the current server
stop()
state = .starting
// Start again after a short delay to allow port release
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.start()
}
}
func toggle() {
if state.isRunning {
stop()
@@ -1,4 +1,6 @@
import Foundation
import AppKit
import UniformTypeIdentifiers
import AppleIntelligenceCore
@MainActor
@@ -9,11 +11,69 @@ final class ChatViewModel {
var isLoading: Bool = false
var errorMessage: String?
// Image attachment state
var pendingImages: [ImageAttachment] = []
private var service: AppleIntelligenceService?
private var currentTask: Task<Void, Never>?
// Maximum images per message
private let maxImagesPerMessage = 5
// Supported image types
static let supportedImageTypes: [UTType] = [.png, .jpeg, .gif, .webP, .heic]
// Recent images from Downloads and Desktop
var recentImages: [URL] = []
func initialize() async {
service = await AppleIntelligenceService()
loadRecentImages()
}
// MARK: - Recent Images
func loadRecentImages() {
let fileManager = FileManager.default
let homeDir = fileManager.homeDirectoryForCurrentUser
let folders = [
homeDir.appendingPathComponent("Downloads"),
homeDir.appendingPathComponent("Desktop")
]
let imageExtensions = ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif"]
var allImages: [(url: URL, date: Date)] = []
for folder in folders {
guard let contents = try? fileManager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
) else { continue }
for url in contents {
let ext = url.pathExtension.lowercased()
guard imageExtensions.contains(ext) else { continue }
if let attributes = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]),
attributes.isRegularFile == true,
let modDate = attributes.contentModificationDate {
allImages.append((url: url, date: modDate))
}
}
}
// Sort by date descending and take last 10
recentImages = allImages
.sorted { $0.date > $1.date }
.prefix(10)
.map { $0.url }
}
func addRecentImage(_ url: URL) {
addImage(from: url)
}
var isServiceAvailable: Bool {
@@ -22,19 +82,77 @@ final class ChatViewModel {
}
}
var canSend: Bool {
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !pendingImages.isEmpty
}
// MARK: - Image Handling
func addImage(from url: URL) {
guard pendingImages.count < maxImagesPerMessage else {
errorMessage = "Maximum \(maxImagesPerMessage) images per message"
return
}
do {
let data = try Data(contentsOf: url)
let attachment = ImageAttachment(data: data, filename: url.lastPathComponent)
pendingImages.append(attachment)
errorMessage = nil
} catch {
errorMessage = "Failed to load image: \(error.localizedDescription)"
}
}
func addImageFromPasteboard() {
guard let image = NSPasteboard.general.readObjects(
forClasses: [NSImage.self],
options: nil
)?.first as? NSImage else {
return
}
guard pendingImages.count < maxImagesPerMessage else {
errorMessage = "Maximum \(maxImagesPerMessage) images per message"
return
}
if let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) {
let attachment = ImageAttachment(data: pngData, filename: "pasted_image.png")
pendingImages.append(attachment)
errorMessage = nil
}
}
func removePendingImage(_ attachment: ImageAttachment) {
pendingImages.removeAll { $0.id == attachment.id }
}
func clearPendingImages() {
pendingImages.removeAll()
}
// MARK: - Messaging
func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
guard !text.isEmpty || !pendingImages.isEmpty else { return }
guard !isLoading else { return }
// Add user message
let userMessage = ChatMessage(role: .user, content: text)
// Capture images before clearing
let imagesToSend = pendingImages
// Add user message with images
let userMessage = ChatMessage(role: .user, content: text, images: imagesToSend)
messages.append(userMessage)
inputText = ""
pendingImages = []
errorMessage = nil
// Add placeholder for assistant response
var assistantMessage = ChatMessage(role: .assistant, content: "", isStreaming: true)
let assistantMessage = ChatMessage(role: .assistant, content: "", isStreaming: true)
messages.append(assistantMessage)
isLoading = true
@@ -45,14 +163,20 @@ final class ChatViewModel {
throw AppleIntelligenceError.modelNotAvailable
}
// Convert attachments to service format
let images = imagesToSend.map { attachment in
(data: attachment.data, filename: attachment.filename)
}
let stream = await service.streamComplete(
prompt: text,
temperature: nil,
maxTokens: nil
maxTokens: nil,
images: images
)
var fullResponse = ""
for try await partialResponse in stream {
for try await (partialResponse, _) in stream {
fullResponse = partialResponse
// Update the last message (assistant's response)
if let index = messages.lastIndex(where: { $0.role == .assistant }) {
+463 -87
View File
@@ -1,97 +1,103 @@
import SwiftUI
import UniformTypeIdentifiers
struct ChatView: View {
@Bindable var viewModel: ChatViewModel
@FocusState private var isInputFocused: Bool
@State private var isShowingFilePicker = false
@State private var isDragOver = false
@State private var previewImageURL: URL?
var body: some View {
VStack(spacing: 0) {
// Messages list
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: viewModel.messages.count) { _, _ in
if let lastMessage = viewModel.messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
.onChange(of: viewModel.messages.last?.content) { _, _ in
if let lastMessage = viewModel.messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
HStack(spacing: 0) {
// Recent images sidebar
if !viewModel.recentImages.isEmpty {
recentImagesSidebar
Divider()
}
// Error message
if let error = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(error)
// Main chat area
VStack(spacing: 0) {
// Messages list
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: viewModel.messages.count) { _, _ in
if let lastMessage = viewModel.messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
.onChange(of: viewModel.messages.last?.content) { _, _ in
if let lastMessage = viewModel.messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
// Error message
if let error = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(error)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Dismiss") {
viewModel.errorMessage = nil
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Dismiss") {
viewModel.errorMessage = nil
}
.buttonStyle(.plain)
.font(.caption)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.red.opacity(0.1))
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.red.opacity(0.1))
Divider()
// Pending images preview
if !viewModel.pendingImages.isEmpty {
pendingImagesView
}
// Input area
inputArea
}
Divider()
// Input area
HStack(spacing: 12) {
TextField("Message...", text: $viewModel.inputText, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.focused($isInputFocused)
.onSubmit {
if !viewModel.inputText.isEmpty {
viewModel.sendMessage()
}
}
if viewModel.isLoading {
Button {
viewModel.stopGeneration()
} label: {
Image(systemName: "stop.circle.fill")
.font(.title2)
.foregroundStyle(.red)
}
.buttonStyle(.plain)
} else {
Button {
viewModel.sendMessage()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(viewModel.inputText.isEmpty ? .gray : .accentColor)
}
.buttonStyle(.plain)
.disabled(viewModel.inputText.isEmpty)
.onDrop(of: [.fileURL, .image], isTargeted: $isDragOver) { providers in
handleDrop(providers: providers)
return true
}
.overlay {
if isDragOver {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.accentColor, lineWidth: 3)
.background(Color.accentColor.opacity(0.1))
.padding(4)
}
}
.padding()
}
.frame(minWidth: 400, minHeight: 500)
.frame(minWidth: 500, minHeight: 500)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
viewModel.loadRecentImages()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Refresh recent images")
}
ToolbarItem(placement: .primaryAction) {
Button {
viewModel.clearChat()
@@ -106,12 +112,10 @@ struct ChatView: View {
await viewModel.initialize()
}
.onAppear {
// Force the app to become active and accept keyboard input
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
// Make sure the window is key
if let window = NSApp.windows.first(where: { $0.title == "Chat" }) {
window.makeKeyAndOrderFront(nil)
}
@@ -119,16 +123,270 @@ struct ChatView: View {
}
}
.onDisappear {
// Return to accessory mode when chat is closed
if NSApp.windows.filter({ $0.isVisible && $0.title != "" }).isEmpty {
NSApp.setActivationPolicy(.accessory)
}
}
.fileImporter(
isPresented: $isShowingFilePicker,
allowedContentTypes: ChatViewModel.supportedImageTypes,
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
for url in urls {
if url.startAccessingSecurityScopedResource() {
viewModel.addImage(from: url)
url.stopAccessingSecurityScopedResource()
}
}
case .failure(let error):
viewModel.errorMessage = error.localizedDescription
}
}
.sheet(item: $previewImageURL) { url in
ImagePreviewSheet(url: url) {
viewModel.addRecentImage(url)
previewImageURL = nil
} onCancel: {
previewImageURL = nil
}
}
}
// MARK: - Drag & Drop Handler
private func handleDrop(providers: [NSItemProvider]) {
for provider in providers {
// Try to load as file URL first
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in
guard error == nil else { return }
if let data = item as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil) {
DispatchQueue.main.async {
viewModel.addImage(from: url)
}
} else if let url = item as? URL {
DispatchQueue.main.async {
viewModel.addImage(from: url)
}
}
}
}
// Try to load as image data
else if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, error in
guard let data = data, error == nil else { return }
DispatchQueue.main.async {
let attachment = ImageAttachment(data: data, filename: "dropped_image.png")
if viewModel.pendingImages.count < 5 {
viewModel.pendingImages.append(attachment)
}
}
}
}
}
}
// MARK: - Recent Images Sidebar
private var recentImagesSidebar: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Recent")
.font(.headline)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.top, 8)
ScrollView {
LazyVStack(spacing: 8) {
ForEach(viewModel.recentImages, id: \.self) { url in
RecentImageThumbnail(url: url) {
previewImageURL = url
}
}
}
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
}
.frame(width: 100)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.5))
}
// MARK: - Pending Images Preview
private var pendingImagesView: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.pendingImages) { attachment in
pendingImageThumbnail(attachment)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.background(Color(nsColor: .controlBackgroundColor))
}
private func pendingImageThumbnail(_ attachment: ImageAttachment) -> some View {
ZStack(alignment: .topTrailing) {
if let thumbnail = attachment.thumbnail {
Image(nsImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "photo")
.foregroundStyle(.secondary)
}
}
Button {
viewModel.removePendingImage(attachment)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.white)
.background(Circle().fill(.black.opacity(0.6)).frame(width: 18, height: 18))
}
.buttonStyle(.plain)
.offset(x: 6, y: -6)
}
}
// MARK: - Input Area
private var inputArea: some View {
HStack(spacing: 8) {
Button {
isShowingFilePicker = true
} label: {
Image(systemName: "photo.badge.plus")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Add image")
Button {
viewModel.addImageFromPasteboard()
} label: {
Image(systemName: "doc.on.clipboard")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Paste image from clipboard")
TextField("Message...", text: $viewModel.inputText, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.focused($isInputFocused)
.onSubmit {
if viewModel.canSend {
viewModel.sendMessage()
}
}
if viewModel.isLoading {
Button {
viewModel.stopGeneration()
} label: {
Image(systemName: "stop.circle.fill")
.font(.title2)
.foregroundStyle(.red)
}
.buttonStyle(.plain)
} else {
Button {
viewModel.sendMessage()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(viewModel.canSend ? Color.accentColor : Color.gray)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSend)
}
}
.padding()
}
}
// MARK: - Recent Image Thumbnail
struct RecentImageThumbnail: View {
let url: URL
let onTap: () -> Void
@State private var thumbnail: NSImage?
var body: some View {
Button(action: onTap) {
ZStack {
if let thumbnail = thumbnail {
Image(nsImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
ProgressView()
.scaleEffect(0.6)
}
}
}
}
.buttonStyle(.plain)
.help(url.lastPathComponent)
.task {
await loadThumbnail()
}
}
private func loadThumbnail() async {
guard let image = NSImage(contentsOf: url) else { return }
let maxSize: CGFloat = 80
let ratio = min(maxSize / image.size.width, maxSize / image.size.height, 1.0)
let newSize = NSSize(
width: image.size.width * ratio,
height: image.size.height * ratio
)
let thumb = NSImage(size: newSize)
thumb.lockFocus()
image.draw(
in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: image.size),
operation: .copy,
fraction: 1.0
)
thumb.unlockFocus()
await MainActor.run {
thumbnail = thumb
}
}
}
// MARK: - Message Bubble
struct MessageBubble: View {
let message: ChatMessage
@State private var showCopied = false
var body: some View {
HStack {
@@ -137,13 +395,19 @@ struct MessageBubble: View {
}
VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) {
Text(message.content)
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(bubbleColor)
.foregroundStyle(message.role == .user ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 16))
if !message.images.isEmpty {
imageGrid
}
if !message.content.isEmpty {
Text(message.content)
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(bubbleColor)
.foregroundStyle(message.role == .user ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
if message.isStreaming {
HStack(spacing: 4) {
@@ -154,6 +418,30 @@ struct MessageBubble: View {
.foregroundStyle(.secondary)
}
}
// Copy button for assistant messages
if message.role == .assistant && !message.content.isEmpty && !message.isStreaming {
HStack {
Spacer()
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(message.content, forType: .string)
showCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
showCopied = false
}
} label: {
HStack(spacing: 4) {
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
Text(showCopied ? "Copied" : "Copy")
}
.font(.caption)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.top, 2)
}
}
if message.role == .assistant {
@@ -162,6 +450,32 @@ struct MessageBubble: View {
}
}
@ViewBuilder
private var imageGrid: some View {
let columns = min(message.images.count, 3)
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: columns),
spacing: 4
) {
ForEach(message.images) { attachment in
if let thumbnail = attachment.thumbnail {
Image(nsImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.padding(4)
.background(
message.role == .user
? Color.accentColor.opacity(0.8)
: Color(nsColor: .controlBackgroundColor)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var bubbleColor: Color {
switch message.role {
case .user:
@@ -171,3 +485,65 @@ struct MessageBubble: View {
}
}
}
// MARK: - Image Preview Sheet
struct ImagePreviewSheet: View {
let url: URL
let onConfirm: () -> Void
let onCancel: () -> Void
@State private var image: NSImage?
var body: some View {
VStack(spacing: 16) {
Text("Add Image")
.font(.headline)
if let image = image {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 500, maxHeight: 400)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(radius: 4)
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
.frame(width: 300, height: 200)
.overlay {
ProgressView()
}
}
Text(url.lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 16) {
Button("Cancel") {
onCancel()
}
.keyboardShortcut(.cancelAction)
Button("Add to Message") {
onConfirm()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(minWidth: 400, minHeight: 300)
.task {
image = NSImage(contentsOf: url)
}
}
}
// MARK: - URL Identifiable Extension
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
@@ -2,6 +2,7 @@ import SwiftUI
struct SettingsView: View {
@Bindable var settings: AppSettings
var serverManager: ServerManager?
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -10,7 +11,7 @@ struct SettingsView: View {
TextField("Host", text: $settings.host)
.textFieldStyle(.roundedBorder)
TextField("Port", value: $settings.port, format: .number)
TextField("Port", value: $settings.port, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
SecureField("API Key (optional)", text: $settings.apiKey)
@@ -22,6 +23,13 @@ struct SettingsView: View {
Toggle("Auto-start server on launch", isOn: $settings.autoStartServer)
}
Section("API") {
Toggle("Enable gRPC reflection", isOn: $settings.enableReflection)
.onChange(of: settings.enableReflection) { _, _ in
serverManager?.restart()
}
}
Section {
HStack {
Button("Reset to Defaults") {
@@ -38,7 +46,7 @@ struct SettingsView: View {
}
}
.formStyle(.grouped)
.frame(width: 400, height: 310)
.frame(width: 400, height: 380)
.fixedSize()
.onAppear {
NSApp.setActivationPolicy(.regular)