From 09b94b32d0bf8e2e32a3b049af2accbfcd995a02 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 18 Mar 2026 14:57:29 +0100 Subject: [PATCH] feat: scene management for RP settings --- MLXServer.xcodeproj/project.pbxproj | 24 ++ MLXServer/Commands/SceneCommands.swift | 14 + MLXServer/ContentView.swift | 24 +- MLXServer/MLXServerApp.swift | 10 + MLXServer/Models/ChatScene.swift | 38 +++ MLXServer/Utilities/Preferences.swift | 27 ++ MLXServer/ViewModels/ChatViewModel.swift | 41 ++- MLXServer/ViewModels/SceneStore.swift | 59 +++++ MLXServer/Views/SceneManagementView.swift | 275 ++++++++++++++++++++ MLXServer/Views/SceneManagementWindow.swift | 5 + MLXServer/Views/SceneSelectionView.swift | 86 ++++++ MLXServer/Views/SettingsView.swift | 22 ++ MLXServer/Views/StatusBarView.swift | 4 + README.md | 10 +- 14 files changed, 635 insertions(+), 4 deletions(-) create mode 100644 MLXServer/Commands/SceneCommands.swift create mode 100644 MLXServer/Models/ChatScene.swift create mode 100644 MLXServer/ViewModels/SceneStore.swift create mode 100644 MLXServer/Views/SceneManagementView.swift create mode 100644 MLXServer/Views/SceneManagementWindow.swift create mode 100644 MLXServer/Views/SceneSelectionView.swift diff --git a/MLXServer.xcodeproj/project.pbxproj b/MLXServer.xcodeproj/project.pbxproj index d780094..c552992 100644 --- a/MLXServer.xcodeproj/project.pbxproj +++ b/MLXServer.xcodeproj/project.pbxproj @@ -8,8 +8,10 @@ /* Begin PBXBuildFile section */ 0168AEE16009097901363E16 /* ModelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 922CBDC9206737BD04AF2874 /* ModelManager.swift */; }; + 07119250A7F9D6ECE7F6B8FD /* SceneCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F03A123A8908714A89315FE /* SceneCommands.swift */; }; 165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145B888FBDD4F931512C5473 /* Preferences.swift */; }; 189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E73B165A1822729C907791AE /* ToolCallParser.swift */; }; + 20FFB5DBF75AA6C359AAE31C /* SceneManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEB592E5E717F817B03151 /* SceneManagementView.swift */; }; 29879D696584B96CC56560DF /* ChatExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */; }; 2CAAF7129F7CC45200FA9F6B /* ModelPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */; }; 2D08769282BD71C170DB0943 /* InferenceStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35452B166893B25E765FF70 /* InferenceStats.swift */; }; @@ -25,23 +27,29 @@ 7CD765C1E2F9F4D7504C8D09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B629DA084A9A40E54F8EA5FA /* Assets.xcassets */; }; 80646C5066BF79BC76E1D9D7 /* ModelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */; }; 84D32315B418B5243E017350 /* ToolPromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */; }; + 85FB1EB49D76A9F21E181346 /* ChatScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */; }; 945474365D0B3E961811909A /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = D5E8E1C2DD8D8AABB4306193 /* MLXVLM */; }; B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239CFF94B819C35A8D4D617 /* MonitorView.swift */; }; B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */; }; B6D3662995B885C102876B4A /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 9090667D4134056AE66DC2F1 /* MLXLMCommon */; }; C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */; }; + CBA88529F8BE7BD0518994AD /* SceneSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B5ABDEB6F5C54856EB1A9E /* SceneSelectionView.swift */; }; + CFEE79815DFB80E51FE3745A /* SceneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C234359924C542F07ED926A2 /* SceneStore.swift */; }; D666A311788375E8A061C832 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4147321383E94E9F17A0154E /* SettingsView.swift */; }; D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A52E2C9964ADA9D841A89B /* APIModels.swift */; }; + DF5C525DBD2E3153256951C1 /* SceneManagementWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA1592FD260014C4FBDB6995 /* SceneManagementWindow.swift */; }; F546CE5955ED253D8A793D5E /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A98257123539E9E738213BFA /* MarkdownUI */; }; FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B359324B5FD8D106C74338 /* ChatMessage.swift */; }; FCD48F8C132A2B830A15EEB4 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = 3F5A4AC6DBAF7CA686ECA74E /* MLXLLM */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0F03A123A8908714A89315FE /* SceneCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneCommands.swift; sourceTree = ""; }; 145B888FBDD4F931512C5473 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolPromptBuilder.swift; sourceTree = ""; }; 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModalView.swift; sourceTree = ""; }; 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChatCommands.swift; sourceTree = ""; }; + 37FEB592E5E717F817B03151 /* SceneManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementView.swift; sourceTree = ""; }; 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfig.swift; sourceTree = ""; }; 3AF462805202797F61422AEE /* MLXServer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MLXServer.entitlements; sourceTree = ""; }; 3D08828E16B17EF02C14243E /* APIServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServer.swift; sourceTree = ""; }; @@ -52,8 +60,12 @@ 944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A4B359324B5FD8D106C74338 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; B0EAB35D7130D56B9E7484BA /* StatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarView.swift; sourceTree = ""; }; + B5B5ABDEB6F5C54856EB1A9E /* SceneSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneSelectionView.swift; sourceTree = ""; }; B629DA084A9A40E54F8EA5FA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; + BA1592FD260014C4FBDB6995 /* SceneManagementWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementWindow.swift; sourceTree = ""; }; + C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScene.swift; sourceTree = ""; }; + C234359924C542F07ED926A2 /* SceneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneStore.swift; sourceTree = ""; }; C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPickerView.swift; sourceTree = ""; }; C67742651DB486871CEF1612 /* MLXServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXServerApp.swift; sourceTree = ""; }; D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalModelResolver.swift; sourceTree = ""; }; @@ -125,6 +137,9 @@ 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */, C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */, 4239CFF94B819C35A8D4D617 /* MonitorView.swift */, + 37FEB592E5E717F817B03151 /* SceneManagementView.swift */, + BA1592FD260014C4FBDB6995 /* SceneManagementWindow.swift */, + B5B5ABDEB6F5C54856EB1A9E /* SceneSelectionView.swift */, 4147321383E94E9F17A0154E /* SettingsView.swift */, B0EAB35D7130D56B9E7484BA /* StatusBarView.swift */, ); @@ -135,6 +150,7 @@ isa = PBXGroup; children = ( 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */, + 0F03A123A8908714A89315FE /* SceneCommands.swift */, ); path = Commands; sourceTree = ""; @@ -143,6 +159,7 @@ isa = PBXGroup; children = ( A4B359324B5FD8D106C74338 /* ChatMessage.swift */, + C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */, E35452B166893B25E765FF70 /* InferenceStats.swift */, 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */, ); @@ -154,6 +171,7 @@ children = ( B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */, 922CBDC9206737BD04AF2874 /* ModelManager.swift */, + C234359924C542F07ED926A2 /* SceneStore.swift */, ); path = ViewModels; sourceTree = ""; @@ -259,6 +277,7 @@ 4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */, FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */, 5C1E8FE1C521914CEF98D3AA /* ChatMessagesView.swift in Sources */, + 85FB1EB49D76A9F21E181346 /* ChatScene.swift in Sources */, B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */, 5946258F1DE88CE904584E0B /* ContentView.swift in Sources */, C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */, @@ -272,6 +291,11 @@ B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */, 165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */, 4158FA884D981D73288FB74C /* SaveChatCommands.swift in Sources */, + 07119250A7F9D6ECE7F6B8FD /* SceneCommands.swift in Sources */, + 20FFB5DBF75AA6C359AAE31C /* SceneManagementView.swift in Sources */, + DF5C525DBD2E3153256951C1 /* SceneManagementWindow.swift in Sources */, + CBA88529F8BE7BD0518994AD /* SceneSelectionView.swift in Sources */, + CFEE79815DFB80E51FE3745A /* SceneStore.swift in Sources */, D666A311788375E8A061C832 /* SettingsView.swift in Sources */, 621B7E4382199AC1378F5F9C /* StatusBarView.swift in Sources */, 189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */, diff --git a/MLXServer/Commands/SceneCommands.swift b/MLXServer/Commands/SceneCommands.swift new file mode 100644 index 0000000..2bd8e82 --- /dev/null +++ b/MLXServer/Commands/SceneCommands.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct SceneCommands: Commands { + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandMenu("Scenes") { + Button("Manage Scenes…") { + openWindow(id: SceneManagementWindow.windowID) + } + .keyboardShortcut(",", modifiers: [.command, .shift]) + } + } +} \ No newline at end of file diff --git a/MLXServer/ContentView.swift b/MLXServer/ContentView.swift index 6f52594..fe94943 100644 --- a/MLXServer/ContentView.swift +++ b/MLXServer/ContentView.swift @@ -3,9 +3,12 @@ import UniformTypeIdentifiers struct ContentView: View { @Environment(ModelManager.self) private var modelManager + @Environment(\.openWindow) private var openWindow + @Environment(SceneStore.self) private var sceneStore @State private var chatVM: ChatViewModel? @State private var showLoadError = false @State private var showMonitor = false + @State private var showScenePicker = false @State private var exportDocument: ChatExportDocument? @State private var exportErrorMessage: String? @@ -142,11 +145,30 @@ struct ContentView: View { // New conversation Button { - chatVM?.newConversation() + showScenePicker = true } label: { Label("New Chat", systemImage: "plus.message") } .keyboardShortcut("n", modifiers: .command) + .popover(isPresented: $showScenePicker, arrowEdge: .top) { + SceneSelectionView( + scenes: sceneStore.scenes, + activeSceneId: chatVM?.activeScene?.id, + currentModelName: modelManager.currentModel?.displayName, + onSelectNeutral: { + showScenePicker = false + Task { await chatVM?.startNewConversation(scene: nil) } + }, + onSelectScene: { scene in + showScenePicker = false + Task { await chatVM?.startNewConversation(scene: scene) } + }, + onManageScenes: { + showScenePicker = false + openWindow(id: SceneManagementWindow.windowID) + } + ) + } } @ViewBuilder diff --git a/MLXServer/MLXServerApp.swift b/MLXServer/MLXServerApp.swift index d542317..9c1f926 100644 --- a/MLXServer/MLXServerApp.swift +++ b/MLXServer/MLXServerApp.swift @@ -4,6 +4,7 @@ import MLX @main struct MLXServerApp: App { @State private var modelManager = ModelManager() + @State private var sceneStore = SceneStore() init() { MLX.GPU.set(cacheLimit: 20 * 1024 * 1024) @@ -13,6 +14,7 @@ struct MLXServerApp: App { WindowGroup { ContentView() .environment(modelManager) + .environment(sceneStore) .task { // Auto-load: configured default → last used → built-in default let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id @@ -25,11 +27,19 @@ struct MLXServerApp: App { .defaultSize(width: 800, height: 700) .commands { SaveChatCommands() + SceneCommands() } + Window("Scenes", id: SceneManagementWindow.windowID) { + SceneManagementView() + .environment(sceneStore) + } + .defaultSize(width: 900, height: 560) + #if os(macOS) Settings { SettingsView() + .environment(sceneStore) } #endif } diff --git a/MLXServer/Models/ChatScene.swift b/MLXServer/Models/ChatScene.swift new file mode 100644 index 0000000..d936ba7 --- /dev/null +++ b/MLXServer/Models/ChatScene.swift @@ -0,0 +1,38 @@ +import Foundation + +struct ChatScene: Codable, Identifiable, Hashable { + let id: UUID + var name: String + var modelId: String? + var systemPrompt: String + var starterPrompt: String + + init( + id: UUID = UUID(), + name: String, + modelId: String? = nil, + systemPrompt: String = "", + starterPrompt: String = "" + ) { + self.id = id + self.name = name + self.modelId = modelId + self.systemPrompt = systemPrompt + self.starterPrompt = starterPrompt + } + + var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var displayName: String { + trimmedName.isEmpty ? "Untitled Scene" : trimmedName + } + + var resolvedModel: ModelConfig? { + guard let modelId else { return nil } + return ModelConfig.availableModels.first(where: { $0.id == modelId }) + } + + static let empty = ChatScene(name: "New Scene") +} \ No newline at end of file diff --git a/MLXServer/Utilities/Preferences.swift b/MLXServer/Utilities/Preferences.swift index e58f3d0..7642d04 100644 --- a/MLXServer/Utilities/Preferences.swift +++ b/MLXServer/Utilities/Preferences.swift @@ -4,6 +4,9 @@ import Foundation enum Preferences { nonisolated(unsafe) private static let defaults = UserDefaults.standard + private static let jsonEncoder = JSONEncoder() + private static let jsonDecoder = JSONDecoder() + // MARK: - Last used model private static let lastModelKey = "lastModelId" @@ -31,6 +34,30 @@ enum Preferences { set { defaults.set(newValue, forKey: systemPromptKey) } } + // MARK: - Scenes + + private static let scenesKey = "chatScenes" + private static let lastSceneIdKey = "lastSceneId" + + static var scenes: [ChatScene] { + get { + guard let data = defaults.data(forKey: scenesKey) else { return [] } + return (try? jsonDecoder.decode([ChatScene].self, from: data)) ?? [] + } + set { + guard let data = try? jsonEncoder.encode(newValue) else { return } + defaults.set(data, forKey: scenesKey) + } + } + + static var lastSceneId: UUID? { + get { + guard let rawValue = defaults.string(forKey: lastSceneIdKey) else { return nil } + return UUID(uuidString: rawValue) + } + set { defaults.set(newValue?.uuidString, forKey: lastSceneIdKey) } + } + // MARK: - API server private static let apiPortKey = "apiPort" diff --git a/MLXServer/ViewModels/ChatViewModel.swift b/MLXServer/ViewModels/ChatViewModel.swift index 76c353a..2d304c4 100644 --- a/MLXServer/ViewModels/ChatViewModel.swift +++ b/MLXServer/ViewModels/ChatViewModel.swift @@ -11,6 +11,7 @@ final class ChatViewModel { var conversation = Conversation() var inputText = "" var attachedImages: [NSImage] = [] + var activeScene: ChatScene? var isGenerating = false var tokensPerSecond: Double = 0 var promptTokens: Int = 0 @@ -26,11 +27,15 @@ final class ChatViewModel { self.modelManager = modelManager } + var activeSceneName: String { + activeScene?.displayName ?? "Neutral" + } + /// Ensure a ChatSession exists for the current model. private func ensureSession() { guard let container = modelManager.modelContainer else { return } if chatSession == nil { - let systemPrompt = Preferences.systemPrompt + let systemPrompt = effectiveSystemPrompt // Pass enable_thinking to the Jinja chat template context. // Qwen3.5 and similar models use this to control reasoning mode. let thinkingContext: [String: any Sendable]? = Preferences.enableThinking @@ -45,6 +50,17 @@ final class ChatViewModel { } } + private var effectiveSystemPrompt: String { + let parts = [ + Preferences.systemPrompt, + activeScene?.systemPrompt ?? "" + ] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return parts.joined(separator: "\n\n") + } + func send() { let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, modelManager.isReady else { return } @@ -147,7 +163,30 @@ final class ChatViewModel { func newConversation() { stop() conversation.clear() + inputText = "" + activeScene = nil resetSession() + Preferences.lastSceneId = nil + } + + func startNewConversation(scene: ChatScene?) async { + stop() + + if let config = scene?.resolvedModel, + modelManager.currentModel?.id != config.id { + await modelManager.loadModel(config) + } + + conversation.clear() + activeScene = scene + inputText = scene?.starterPrompt.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + attachedImages = [] + resetSession() + Preferences.lastSceneId = scene?.id + + if !inputText.isEmpty { + send() + } } /// Reset the chat session (e.g. on model switch or new conversation). diff --git a/MLXServer/ViewModels/SceneStore.swift b/MLXServer/ViewModels/SceneStore.swift new file mode 100644 index 0000000..3f3cf34 --- /dev/null +++ b/MLXServer/ViewModels/SceneStore.swift @@ -0,0 +1,59 @@ +import Foundation + +@Observable +@MainActor +final class SceneStore { + var scenes: [ChatScene] + + init() { + self.scenes = Preferences.scenes + } + + func addScene(copying scene: ChatScene? = nil) -> ChatScene { + let nextScene: ChatScene + if let scene { + nextScene = ChatScene( + name: scene.displayName, + modelId: scene.modelId, + systemPrompt: scene.systemPrompt, + starterPrompt: scene.starterPrompt + ) + } else { + nextScene = .empty + } + scenes.append(nextScene) + persist() + return nextScene + } + + func updateScene(id: UUID, _ mutate: (inout ChatScene) -> Void) { + guard let index = scenes.firstIndex(where: { $0.id == id }) else { return } + mutate(&scenes[index]) + persist() + } + + func deleteScene(id: UUID) { + scenes.removeAll { $0.id == id } + persist() + } + + func deleteScenes(ids: some Sequence) { + let idsToDelete = Set(ids) + scenes.removeAll { idsToDelete.contains($0.id) } + persist() + } + + func deleteScenes(at offsets: IndexSet) { + scenes.remove(atOffsets: offsets) + persist() + } + + func scene(id: UUID?) -> ChatScene? { + guard let id else { return nil } + return scenes.first(where: { $0.id == id }) + } + + private func persist() { + Preferences.scenes = scenes + } +} \ No newline at end of file diff --git a/MLXServer/Views/SceneManagementView.swift b/MLXServer/Views/SceneManagementView.swift new file mode 100644 index 0000000..20ff1a1 --- /dev/null +++ b/MLXServer/Views/SceneManagementView.swift @@ -0,0 +1,275 @@ +import SwiftUI + +struct SceneManagementView: View { + @Environment(SceneStore.self) private var sceneStore + + @State private var selectedSceneId: UUID? + @State private var renamingSceneId: UUID? + @State private var renameDraft = "" + @State private var pendingDeleteSceneIDs: [UUID] = [] + @FocusState private var focusedRenameSceneId: UUID? + + var body: some View { + NavigationSplitView { + Group { + if sceneStore.scenes.isEmpty { + ContentUnavailableView( + "No Scenes Yet", + systemImage: "theatermasks", + description: Text("Use the add button in the toolbar to create a scene.") + ) + } else { + List(selection: $selectedSceneId) { + ForEach(sceneStore.scenes) { scene in + VStack(alignment: .leading, spacing: 2) { + if renamingSceneId == scene.id { + TextField("Scene Name", text: $renameDraft) + .textFieldStyle(.roundedBorder) + .focused($focusedRenameSceneId, equals: scene.id) + .onSubmit { + commitRename(for: scene.id) + } + } else { + Text(scene.displayName) + } + + Text(scene.resolvedModel?.displayName ?? "Current model") + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(scene.id) + .onTapGesture(count: 2) { + beginRename(scene) + } + .contextMenu { + Button("Rename") { + beginRename(scene) + } + Button("Duplicate") { + duplicateScene(scene) + } + Divider() + Button("Delete", role: .destructive) { + confirmDelete(sceneIDs: [scene.id]) + } + } + } + .onDelete(perform: deleteScenes) + } + .navigationTitle("Scenes") + .listStyle(.sidebar) + } + } + } detail: { + if let selectedScene = sceneStore.scene(id: selectedSceneId) { + SceneEditorView(scene: selectedScene) + } else { + ContentUnavailableView( + "No Scene Selected", + systemImage: "slider.horizontal.3", + description: Text("Select a scene in the sidebar or create one from the toolbar.") + ) + } + } + .navigationTitle("Scenes") + .frame(minWidth: 760, minHeight: 480) + .toolbar { + ToolbarItemGroup { + Button { + createAndSelectScene() + } label: { + Label("New Scene", systemImage: "plus") + } + + Button { + duplicateSelectedScene() + } label: { + Label("Duplicate Scene", systemImage: "plus.square.on.square") + } + .disabled(sceneStore.scene(id: selectedSceneId) == nil) + + Button(role: .destructive) { + if let selectedSceneId { + confirmDelete(sceneIDs: [selectedSceneId]) + } + } label: { + Label("Delete Scene", systemImage: "trash") + } + .disabled(sceneStore.scene(id: selectedSceneId) == nil) + } + } + .confirmationDialog( + deleteDialogTitle, + isPresented: deleteConfirmationBinding, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + performConfirmedDelete() + } + Button("Cancel", role: .cancel) { + pendingDeleteSceneIDs = [] + } + } message: { + Text(deleteDialogMessage) + } + .onAppear { + if selectedSceneId == nil { + selectedSceneId = sceneStore.scenes.first?.id + } + } + .onChange(of: sceneStore.scenes.count) { + if sceneStore.scene(id: selectedSceneId) == nil { + selectedSceneId = sceneStore.scenes.first?.id + } + } + } + + private func deleteScenes(at offsets: IndexSet) { + let sceneIDs = offsets.map { sceneStore.scenes[$0].id } + confirmDelete(sceneIDs: sceneIDs) + } + + private func beginRename(_ scene: ChatScene) { + selectedSceneId = scene.id + renamingSceneId = scene.id + renameDraft = scene.displayName + focusedRenameSceneId = scene.id + } + + private func commitRename(for id: UUID) { + let trimmedName = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + sceneStore.updateScene(id: id) { + $0.name = trimmedName.isEmpty ? "Untitled Scene" : trimmedName + } + renamingSceneId = nil + focusedRenameSceneId = nil + } + + private func confirmDelete(sceneIDs: [UUID]) { + pendingDeleteSceneIDs = sceneIDs + } + + private func performConfirmedDelete() { + let idsToDelete = Set(pendingDeleteSceneIDs) + pendingDeleteSceneIDs = [] + sceneStore.deleteScenes(ids: idsToDelete) + if let selectedSceneId, idsToDelete.contains(selectedSceneId) { + self.selectedSceneId = sceneStore.scenes.first?.id + } + if let renamingSceneId, idsToDelete.contains(renamingSceneId) { + self.renamingSceneId = nil + focusedRenameSceneId = nil + renameDraft = "" + } + } + + private func createAndSelectScene() { + let created = sceneStore.addScene() + selectedSceneId = created.id + } + + private func duplicateSelectedScene() { + guard let selectedScene = sceneStore.scene(id: selectedSceneId) else { return } + duplicateScene(selectedScene) + } + + private func duplicateScene(_ scene: ChatScene) { + let duplicated = sceneStore.addScene(copying: scene) + selectedSceneId = duplicated.id + } + + private func deleteScene(_ id: UUID) { + sceneStore.deleteScene(id: id) + if selectedSceneId == id { + selectedSceneId = sceneStore.scenes.first?.id + } + } + + private var deleteConfirmationBinding: Binding { + Binding( + get: { !pendingDeleteSceneIDs.isEmpty }, + set: { isPresented in + if !isPresented { + pendingDeleteSceneIDs = [] + } + } + ) + } + + private var deleteDialogTitle: String { + pendingDeleteSceneIDs.count == 1 ? "Delete Scene?" : "Delete Scenes?" + } + + private var deleteDialogMessage: String { + if pendingDeleteSceneIDs.count == 1, + let scene = sceneStore.scene(id: pendingDeleteSceneIDs.first) { + return "\"\(scene.displayName)\" will be removed from your saved scenes." + } + return "\(pendingDeleteSceneIDs.count) scenes will be removed from your saved scenes." + } +} + +private struct SceneEditorView: View { + @Environment(SceneStore.self) private var sceneStore + + let scene: ChatScene + + var body: some View { + Form { + Section("Details") { + TextField("Name", text: binding(for: \.name)) + + Picker("Model", selection: modelBinding) { + Text("Current model").tag(Optional.none) + ForEach(ModelConfig.availableModels) { model in + Text(model.displayName).tag(Optional(model.id)) + } + } + } + + Section("Scene Prompt") { + TextEditor(text: binding(for: \.systemPrompt)) + .font(.body.monospaced()) + .frame(minHeight: 150) + + Text("Appended after the base system prompt when this scene starts a new chat.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Starter Prompt") { + TextEditor(text: binding(for: \.starterPrompt)) + .font(.body.monospaced()) + .frame(minHeight: 120) + + Text("Sent automatically as the first user message when this scene starts a new chat.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .navigationTitle(scene.displayName) + } + + private var modelBinding: Binding { + Binding( + get: { sceneStore.scene(id: scene.id)?.modelId }, + set: { newValue in + sceneStore.updateScene(id: scene.id) { + $0.modelId = newValue + } + } + ) + } + + private func binding(for keyPath: WritableKeyPath) -> Binding { + Binding( + get: { sceneStore.scene(id: scene.id)?[keyPath: keyPath] ?? scene[keyPath: keyPath] }, + set: { newValue in + sceneStore.updateScene(id: scene.id) { + $0[keyPath: keyPath] = newValue + } + } + ) + } +} \ No newline at end of file diff --git a/MLXServer/Views/SceneManagementWindow.swift b/MLXServer/Views/SceneManagementWindow.swift new file mode 100644 index 0000000..64bd0d2 --- /dev/null +++ b/MLXServer/Views/SceneManagementWindow.swift @@ -0,0 +1,5 @@ +import Foundation + +enum SceneManagementWindow { + static let windowID = "scene-manager" +} \ No newline at end of file diff --git a/MLXServer/Views/SceneSelectionView.swift b/MLXServer/Views/SceneSelectionView.swift new file mode 100644 index 0000000..ece3dd5 --- /dev/null +++ b/MLXServer/Views/SceneSelectionView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct SceneSelectionView: View { + let scenes: [ChatScene] + let activeSceneId: UUID? + let currentModelName: String? + let onSelectNeutral: () -> Void + let onSelectScene: (ChatScene) -> Void + let onManageScenes: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Start New Chat") + .font(.headline) + + sceneButton( + title: "Neutral", + subtitle: currentModelName.map { "Keeps \($0) and only uses the base system prompt." } + ?? "Keeps the current model and only uses the base system prompt.", + isSelected: activeSceneId == nil, + action: onSelectNeutral + ) + + if !scenes.isEmpty { + Divider() + + ForEach(scenes) { scene in + sceneButton( + title: scene.displayName, + subtitle: sceneSubtitle(for: scene), + isSelected: activeSceneId == scene.id, + action: { onSelectScene(scene) } + ) + } + } + + Divider() + + Button("Manage Scenes…", action: onManageScenes) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(width: 320) + } + + @ViewBuilder + private func sceneButton( + title: String, + subtitle: String, + isSelected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary.opacity(0.45)) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .foregroundStyle(.primary) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(isSelected ? Color.accentColor.opacity(0.08) : Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func sceneSubtitle(for scene: ChatScene) -> String { + let modelText = scene.resolvedModel?.displayName ?? "Current model" + if scene.systemPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "\(modelText) • No extra scene prompt" + } + return "\(modelText) • Adds scene prompt" + } +} \ No newline at end of file diff --git a/MLXServer/Views/SettingsView.swift b/MLXServer/Views/SettingsView.swift index 2c31acf..fdb4796 100644 --- a/MLXServer/Views/SettingsView.swift +++ b/MLXServer/Views/SettingsView.swift @@ -1,6 +1,8 @@ import SwiftUI struct SettingsView: View { + @Environment(\.openWindow) private var openWindow + @Environment(SceneStore.self) private var sceneStore @State private var systemPrompt: String = Preferences.systemPrompt @State private var apiPort: String = String(Preferences.apiPort) @State private var apiAutoStart: Bool = Preferences.apiAutoStart @@ -49,6 +51,26 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Scenes") { + Button("Manage Scenes…") { + openWindow(id: SceneManagementWindow.windowID) + } + + Text("Create reusable roleplay or task presets with a dedicated model, extra system prompt, and an auto-sent opening message.") + .font(.caption) + .foregroundStyle(.secondary) + + if sceneStore.scenes.isEmpty { + Text("No saved scenes yet.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Saved scenes: \(sceneStore.scenes.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Section("API Server") { HStack { Text("Port") diff --git a/MLXServer/Views/StatusBarView.swift b/MLXServer/Views/StatusBarView.swift index 1849442..917bc37 100644 --- a/MLXServer/Views/StatusBarView.swift +++ b/MLXServer/Views/StatusBarView.swift @@ -27,6 +27,10 @@ struct StatusBarView: View { .foregroundStyle(.secondary) } + Label(viewModel.activeSceneName, systemImage: "theatermasks") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() // GPU memory diff --git a/README.md b/README.md index cbac6d8..c136daf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ open "build/Debug/MLX Server.app" ## App Features - **Chat interface** with markdown rendering and model-aware image attachments (file picker, drag & drop, clipboard paste, Finder copy-paste on vision-capable models) +- **Scene-based chat starts** — New Chat opens a scene picker with Neutral plus saved scenes, each with an optional model override, a scene prompt layered onto the base system prompt, and an auto-sent starter prompt - **Model picker** in toolbar with local/download status indicators and re-download button - **Download progress modal** — shows file progress, percentage, and speed when downloading a new model - **Thinking mode** — models like Qwen3.5 can reason internally before responding; thinking content appears in a collapsible box. Toggle on/off in Settings. @@ -34,7 +35,8 @@ open "build/Debug/MLX Server.app" - **Export chat** — File > Export Chat (Cmd+Shift+S) saves conversations as Markdown or RTF (Pages-compatible) - **Status bar** showing model name, context window, tokens/sec, token counts, GPU memory, API server status - **Keyboard shortcuts**: `Cmd+N` (new chat), `Cmd+Return` (send), `Escape` (stop), `Cmd+1/2/3/4` (switch models), `Cmd+Shift+S` (export) -- **Settings** (`Cmd+,`): default model, thinking mode toggle, system prompt, API port, API auto-start, idle unload timeout +- **Scene management** — create and edit reusable roleplay/task presets from the New Chat flow or Settings +- **Settings** (`Cmd+,`): default model, thinking mode toggle, base system prompt, scene management, API port, API auto-start, idle unload timeout - **Idle auto-unload** — model is unloaded after configurable idle time (resets on both user input and model output), reloaded on next request ## API Server @@ -91,10 +93,14 @@ MLXServer/ ├── Models/ │ ├── ModelConfig.swift — Model definitions, alias/repoId resolution │ └── ChatMessage.swift — Chat message data model, thinking tag parser +│ └── ChatScene.swift — Persisted chat scene presets (prompt + model + starter) ├── ViewModels/ │ ├── ModelManager.swift — Model loading/switching, download tracking, idle unload │ └── ChatViewModel.swift — Chat state, ChatSession, API server lifecycle +│ └── SceneStore.swift — Scene persistence and editing operations ├── Views/ +│ ├── SceneSelectionView.swift — New chat scene picker popover +│ ├── SceneManagementView.swift — Scene editor and list management │ ├── ModelPickerView.swift — Toolbar model selector with re-download │ ├── ChatMessagesView.swift — Scrollable message list with markdown + thinking blocks │ ├── ChatInputView.swift — Text input + image attach (paste, drag, picker) @@ -113,7 +119,7 @@ MLXServer/ ├── LocalModelResolver.swift — Offline-first HuggingFace cache resolution (sandbox + system) ├── ChatExporter.swift — Export conversations to Markdown or RTF ├── FocusedValues.swift — FocusedValue keys for menu bar integration - └── Preferences.swift — UserDefaults wrapper + └── Preferences.swift — UserDefaults wrapper, including scene persistence project.yml — xcodegen project spec (dependencies, settings, deployment target) build.sh — One-command build script (xcodegen + xcodebuild)