feat: scene management for RP settings
This commit is contained in:
@@ -8,8 +8,10 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0168AEE16009097901363E16 /* ModelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 922CBDC9206737BD04AF2874 /* ModelManager.swift */; };
|
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 */; };
|
165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145B888FBDD4F931512C5473 /* Preferences.swift */; };
|
||||||
189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E73B165A1822729C907791AE /* ToolCallParser.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 */; };
|
29879D696584B96CC56560DF /* ChatExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */; };
|
||||||
2CAAF7129F7CC45200FA9F6B /* ModelPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */; };
|
2CAAF7129F7CC45200FA9F6B /* ModelPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */; };
|
||||||
2D08769282BD71C170DB0943 /* InferenceStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = E35452B166893B25E765FF70 /* InferenceStats.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 */; };
|
7CD765C1E2F9F4D7504C8D09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B629DA084A9A40E54F8EA5FA /* Assets.xcassets */; };
|
||||||
80646C5066BF79BC76E1D9D7 /* ModelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */; };
|
80646C5066BF79BC76E1D9D7 /* ModelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */; };
|
||||||
84D32315B418B5243E017350 /* ToolPromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.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 */; };
|
945474365D0B3E961811909A /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = D5E8E1C2DD8D8AABB4306193 /* MLXVLM */; };
|
||||||
B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239CFF94B819C35A8D4D617 /* MonitorView.swift */; };
|
B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239CFF94B819C35A8D4D617 /* MonitorView.swift */; };
|
||||||
B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */; };
|
B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */; };
|
||||||
B6D3662995B885C102876B4A /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 9090667D4134056AE66DC2F1 /* MLXLMCommon */; };
|
B6D3662995B885C102876B4A /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 9090667D4134056AE66DC2F1 /* MLXLMCommon */; };
|
||||||
C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */; };
|
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 */; };
|
D666A311788375E8A061C832 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4147321383E94E9F17A0154E /* SettingsView.swift */; };
|
||||||
D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A52E2C9964ADA9D841A89B /* APIModels.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 */; };
|
F546CE5955ED253D8A793D5E /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A98257123539E9E738213BFA /* MarkdownUI */; };
|
||||||
FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B359324B5FD8D106C74338 /* ChatMessage.swift */; };
|
FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B359324B5FD8D106C74338 /* ChatMessage.swift */; };
|
||||||
FCD48F8C132A2B830A15EEB4 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = 3F5A4AC6DBAF7CA686ECA74E /* MLXLLM */; };
|
FCD48F8C132A2B830A15EEB4 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = 3F5A4AC6DBAF7CA686ECA74E /* MLXLLM */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
0F03A123A8908714A89315FE /* SceneCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneCommands.swift; sourceTree = "<group>"; };
|
||||||
145B888FBDD4F931512C5473 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
145B888FBDD4F931512C5473 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
||||||
16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolPromptBuilder.swift; sourceTree = "<group>"; };
|
16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolPromptBuilder.swift; sourceTree = "<group>"; };
|
||||||
2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModalView.swift; sourceTree = "<group>"; };
|
2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModalView.swift; sourceTree = "<group>"; };
|
||||||
2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChatCommands.swift; sourceTree = "<group>"; };
|
2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChatCommands.swift; sourceTree = "<group>"; };
|
||||||
|
37FEB592E5E717F817B03151 /* SceneManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementView.swift; sourceTree = "<group>"; };
|
||||||
38DFC212AF4359A45FBE22BA /* ModelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfig.swift; sourceTree = "<group>"; };
|
38DFC212AF4359A45FBE22BA /* ModelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfig.swift; sourceTree = "<group>"; };
|
||||||
3AF462805202797F61422AEE /* MLXServer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MLXServer.entitlements; sourceTree = "<group>"; };
|
3AF462805202797F61422AEE /* MLXServer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MLXServer.entitlements; sourceTree = "<group>"; };
|
||||||
3D08828E16B17EF02C14243E /* APIServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServer.swift; sourceTree = "<group>"; };
|
3D08828E16B17EF02C14243E /* APIServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServer.swift; sourceTree = "<group>"; };
|
||||||
@@ -52,8 +60,12 @@
|
|||||||
944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
A4B359324B5FD8D106C74338 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
|
A4B359324B5FD8D106C74338 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
|
||||||
B0EAB35D7130D56B9E7484BA /* StatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarView.swift; sourceTree = "<group>"; };
|
B0EAB35D7130D56B9E7484BA /* StatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarView.swift; sourceTree = "<group>"; };
|
||||||
|
B5B5ABDEB6F5C54856EB1A9E /* SceneSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneSelectionView.swift; sourceTree = "<group>"; };
|
||||||
B629DA084A9A40E54F8EA5FA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
B629DA084A9A40E54F8EA5FA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
BA1592FD260014C4FBDB6995 /* SceneManagementWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementWindow.swift; sourceTree = "<group>"; };
|
||||||
|
C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScene.swift; sourceTree = "<group>"; };
|
||||||
|
C234359924C542F07ED926A2 /* SceneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneStore.swift; sourceTree = "<group>"; };
|
||||||
C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPickerView.swift; sourceTree = "<group>"; };
|
C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPickerView.swift; sourceTree = "<group>"; };
|
||||||
C67742651DB486871CEF1612 /* MLXServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXServerApp.swift; sourceTree = "<group>"; };
|
C67742651DB486871CEF1612 /* MLXServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXServerApp.swift; sourceTree = "<group>"; };
|
||||||
D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalModelResolver.swift; sourceTree = "<group>"; };
|
D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalModelResolver.swift; sourceTree = "<group>"; };
|
||||||
@@ -125,6 +137,9 @@
|
|||||||
2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */,
|
2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */,
|
||||||
C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */,
|
C3C3A76C02AF70A9D8F868FC /* ModelPickerView.swift */,
|
||||||
4239CFF94B819C35A8D4D617 /* MonitorView.swift */,
|
4239CFF94B819C35A8D4D617 /* MonitorView.swift */,
|
||||||
|
37FEB592E5E717F817B03151 /* SceneManagementView.swift */,
|
||||||
|
BA1592FD260014C4FBDB6995 /* SceneManagementWindow.swift */,
|
||||||
|
B5B5ABDEB6F5C54856EB1A9E /* SceneSelectionView.swift */,
|
||||||
4147321383E94E9F17A0154E /* SettingsView.swift */,
|
4147321383E94E9F17A0154E /* SettingsView.swift */,
|
||||||
B0EAB35D7130D56B9E7484BA /* StatusBarView.swift */,
|
B0EAB35D7130D56B9E7484BA /* StatusBarView.swift */,
|
||||||
);
|
);
|
||||||
@@ -135,6 +150,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */,
|
2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */,
|
||||||
|
0F03A123A8908714A89315FE /* SceneCommands.swift */,
|
||||||
);
|
);
|
||||||
path = Commands;
|
path = Commands;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -143,6 +159,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A4B359324B5FD8D106C74338 /* ChatMessage.swift */,
|
A4B359324B5FD8D106C74338 /* ChatMessage.swift */,
|
||||||
|
C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */,
|
||||||
E35452B166893B25E765FF70 /* InferenceStats.swift */,
|
E35452B166893B25E765FF70 /* InferenceStats.swift */,
|
||||||
38DFC212AF4359A45FBE22BA /* ModelConfig.swift */,
|
38DFC212AF4359A45FBE22BA /* ModelConfig.swift */,
|
||||||
);
|
);
|
||||||
@@ -154,6 +171,7 @@
|
|||||||
children = (
|
children = (
|
||||||
B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */,
|
B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */,
|
||||||
922CBDC9206737BD04AF2874 /* ModelManager.swift */,
|
922CBDC9206737BD04AF2874 /* ModelManager.swift */,
|
||||||
|
C234359924C542F07ED926A2 /* SceneStore.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -259,6 +277,7 @@
|
|||||||
4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */,
|
4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */,
|
||||||
FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */,
|
FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */,
|
||||||
5C1E8FE1C521914CEF98D3AA /* ChatMessagesView.swift in Sources */,
|
5C1E8FE1C521914CEF98D3AA /* ChatMessagesView.swift in Sources */,
|
||||||
|
85FB1EB49D76A9F21E181346 /* ChatScene.swift in Sources */,
|
||||||
B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */,
|
B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */,
|
||||||
5946258F1DE88CE904584E0B /* ContentView.swift in Sources */,
|
5946258F1DE88CE904584E0B /* ContentView.swift in Sources */,
|
||||||
C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */,
|
C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */,
|
||||||
@@ -272,6 +291,11 @@
|
|||||||
B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */,
|
B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */,
|
||||||
165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */,
|
165E8AB6ADAE1D59B1A86420 /* Preferences.swift in Sources */,
|
||||||
4158FA884D981D73288FB74C /* SaveChatCommands.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 */,
|
D666A311788375E8A061C832 /* SettingsView.swift in Sources */,
|
||||||
621B7E4382199AC1378F5F9C /* StatusBarView.swift in Sources */,
|
621B7E4382199AC1378F5F9C /* StatusBarView.swift in Sources */,
|
||||||
189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */,
|
189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */,
|
||||||
|
|||||||
14
MLXServer/Commands/SceneCommands.swift
Normal file
14
MLXServer/Commands/SceneCommands.swift
Normal file
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(ModelManager.self) private var modelManager
|
@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 chatVM: ChatViewModel?
|
||||||
@State private var showLoadError = false
|
@State private var showLoadError = false
|
||||||
@State private var showMonitor = false
|
@State private var showMonitor = false
|
||||||
|
@State private var showScenePicker = false
|
||||||
@State private var exportDocument: ChatExportDocument?
|
@State private var exportDocument: ChatExportDocument?
|
||||||
@State private var exportErrorMessage: String?
|
@State private var exportErrorMessage: String?
|
||||||
|
|
||||||
@@ -142,11 +145,30 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// New conversation
|
// New conversation
|
||||||
Button {
|
Button {
|
||||||
chatVM?.newConversation()
|
showScenePicker = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("New Chat", systemImage: "plus.message")
|
Label("New Chat", systemImage: "plus.message")
|
||||||
}
|
}
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.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
|
@ViewBuilder
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import MLX
|
|||||||
@main
|
@main
|
||||||
struct MLXServerApp: App {
|
struct MLXServerApp: App {
|
||||||
@State private var modelManager = ModelManager()
|
@State private var modelManager = ModelManager()
|
||||||
|
@State private var sceneStore = SceneStore()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
MLX.GPU.set(cacheLimit: 20 * 1024 * 1024)
|
MLX.GPU.set(cacheLimit: 20 * 1024 * 1024)
|
||||||
@@ -13,6 +14,7 @@ struct MLXServerApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(modelManager)
|
.environment(modelManager)
|
||||||
|
.environment(sceneStore)
|
||||||
.task {
|
.task {
|
||||||
// Auto-load: configured default → last used → built-in default
|
// Auto-load: configured default → last used → built-in default
|
||||||
let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id
|
let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id
|
||||||
@@ -25,11 +27,19 @@ struct MLXServerApp: App {
|
|||||||
.defaultSize(width: 800, height: 700)
|
.defaultSize(width: 800, height: 700)
|
||||||
.commands {
|
.commands {
|
||||||
SaveChatCommands()
|
SaveChatCommands()
|
||||||
|
SceneCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Window("Scenes", id: SceneManagementWindow.windowID) {
|
||||||
|
SceneManagementView()
|
||||||
|
.environment(sceneStore)
|
||||||
|
}
|
||||||
|
.defaultSize(width: 900, height: 560)
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
|
.environment(sceneStore)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
38
MLXServer/Models/ChatScene.swift
Normal file
38
MLXServer/Models/ChatScene.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import Foundation
|
|||||||
enum Preferences {
|
enum Preferences {
|
||||||
nonisolated(unsafe) private static let defaults = UserDefaults.standard
|
nonisolated(unsafe) private static let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
private static let jsonEncoder = JSONEncoder()
|
||||||
|
private static let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
// MARK: - Last used model
|
// MARK: - Last used model
|
||||||
|
|
||||||
private static let lastModelKey = "lastModelId"
|
private static let lastModelKey = "lastModelId"
|
||||||
@@ -31,6 +34,30 @@ enum Preferences {
|
|||||||
set { defaults.set(newValue, forKey: systemPromptKey) }
|
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
|
// MARK: - API server
|
||||||
|
|
||||||
private static let apiPortKey = "apiPort"
|
private static let apiPortKey = "apiPort"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class ChatViewModel {
|
|||||||
var conversation = Conversation()
|
var conversation = Conversation()
|
||||||
var inputText = ""
|
var inputText = ""
|
||||||
var attachedImages: [NSImage] = []
|
var attachedImages: [NSImage] = []
|
||||||
|
var activeScene: ChatScene?
|
||||||
var isGenerating = false
|
var isGenerating = false
|
||||||
var tokensPerSecond: Double = 0
|
var tokensPerSecond: Double = 0
|
||||||
var promptTokens: Int = 0
|
var promptTokens: Int = 0
|
||||||
@@ -26,11 +27,15 @@ final class ChatViewModel {
|
|||||||
self.modelManager = modelManager
|
self.modelManager = modelManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var activeSceneName: String {
|
||||||
|
activeScene?.displayName ?? "Neutral"
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure a ChatSession exists for the current model.
|
/// Ensure a ChatSession exists for the current model.
|
||||||
private func ensureSession() {
|
private func ensureSession() {
|
||||||
guard let container = modelManager.modelContainer else { return }
|
guard let container = modelManager.modelContainer else { return }
|
||||||
if chatSession == nil {
|
if chatSession == nil {
|
||||||
let systemPrompt = Preferences.systemPrompt
|
let systemPrompt = effectiveSystemPrompt
|
||||||
// Pass enable_thinking to the Jinja chat template context.
|
// Pass enable_thinking to the Jinja chat template context.
|
||||||
// Qwen3.5 and similar models use this to control reasoning mode.
|
// Qwen3.5 and similar models use this to control reasoning mode.
|
||||||
let thinkingContext: [String: any Sendable]? = Preferences.enableThinking
|
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() {
|
func send() {
|
||||||
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty, modelManager.isReady else { return }
|
guard !text.isEmpty, modelManager.isReady else { return }
|
||||||
@@ -147,7 +163,30 @@ final class ChatViewModel {
|
|||||||
func newConversation() {
|
func newConversation() {
|
||||||
stop()
|
stop()
|
||||||
conversation.clear()
|
conversation.clear()
|
||||||
|
inputText = ""
|
||||||
|
activeScene = nil
|
||||||
resetSession()
|
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).
|
/// Reset the chat session (e.g. on model switch or new conversation).
|
||||||
|
|||||||
59
MLXServer/ViewModels/SceneStore.swift
Normal file
59
MLXServer/ViewModels/SceneStore.swift
Normal file
@@ -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<UUID>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
275
MLXServer/Views/SceneManagementView.swift
Normal file
275
MLXServer/Views/SceneManagementView.swift
Normal file
@@ -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<Bool> {
|
||||||
|
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<String>.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<String?> {
|
||||||
|
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<ChatScene, String>) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { sceneStore.scene(id: scene.id)?[keyPath: keyPath] ?? scene[keyPath: keyPath] },
|
||||||
|
set: { newValue in
|
||||||
|
sceneStore.updateScene(id: scene.id) {
|
||||||
|
$0[keyPath: keyPath] = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
MLXServer/Views/SceneManagementWindow.swift
Normal file
5
MLXServer/Views/SceneManagementWindow.swift
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SceneManagementWindow {
|
||||||
|
static let windowID = "scene-manager"
|
||||||
|
}
|
||||||
86
MLXServer/Views/SceneSelectionView.swift
Normal file
86
MLXServer/Views/SceneSelectionView.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@Environment(SceneStore.self) private var sceneStore
|
||||||
@State private var systemPrompt: String = Preferences.systemPrompt
|
@State private var systemPrompt: String = Preferences.systemPrompt
|
||||||
@State private var apiPort: String = String(Preferences.apiPort)
|
@State private var apiPort: String = String(Preferences.apiPort)
|
||||||
@State private var apiAutoStart: Bool = Preferences.apiAutoStart
|
@State private var apiAutoStart: Bool = Preferences.apiAutoStart
|
||||||
@@ -49,6 +51,26 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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") {
|
Section("API Server") {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Port")
|
Text("Port")
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ struct StatusBarView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Label(viewModel.activeSceneName, systemImage: "theatermasks")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// GPU memory
|
// GPU memory
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -27,6 +27,7 @@ open "build/Debug/MLX Server.app"
|
|||||||
## App Features
|
## 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)
|
- **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
|
- **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
|
- **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.
|
- **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)
|
- **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
|
- **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)
|
- **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
|
- **Idle auto-unload** — model is unloaded after configurable idle time (resets on both user input and model output), reloaded on next request
|
||||||
|
|
||||||
## API Server
|
## API Server
|
||||||
@@ -91,10 +93,14 @@ MLXServer/
|
|||||||
├── Models/
|
├── Models/
|
||||||
│ ├── ModelConfig.swift — Model definitions, alias/repoId resolution
|
│ ├── ModelConfig.swift — Model definitions, alias/repoId resolution
|
||||||
│ └── ChatMessage.swift — Chat message data model, thinking tag parser
|
│ └── ChatMessage.swift — Chat message data model, thinking tag parser
|
||||||
|
│ └── ChatScene.swift — Persisted chat scene presets (prompt + model + starter)
|
||||||
├── ViewModels/
|
├── ViewModels/
|
||||||
│ ├── ModelManager.swift — Model loading/switching, download tracking, idle unload
|
│ ├── ModelManager.swift — Model loading/switching, download tracking, idle unload
|
||||||
│ └── ChatViewModel.swift — Chat state, ChatSession, API server lifecycle
|
│ └── ChatViewModel.swift — Chat state, ChatSession, API server lifecycle
|
||||||
|
│ └── SceneStore.swift — Scene persistence and editing operations
|
||||||
├── Views/
|
├── Views/
|
||||||
|
│ ├── SceneSelectionView.swift — New chat scene picker popover
|
||||||
|
│ ├── SceneManagementView.swift — Scene editor and list management
|
||||||
│ ├── ModelPickerView.swift — Toolbar model selector with re-download
|
│ ├── ModelPickerView.swift — Toolbar model selector with re-download
|
||||||
│ ├── ChatMessagesView.swift — Scrollable message list with markdown + thinking blocks
|
│ ├── ChatMessagesView.swift — Scrollable message list with markdown + thinking blocks
|
||||||
│ ├── ChatInputView.swift — Text input + image attach (paste, drag, picker)
|
│ ├── ChatInputView.swift — Text input + image attach (paste, drag, picker)
|
||||||
@@ -113,7 +119,7 @@ MLXServer/
|
|||||||
├── LocalModelResolver.swift — Offline-first HuggingFace cache resolution (sandbox + system)
|
├── LocalModelResolver.swift — Offline-first HuggingFace cache resolution (sandbox + system)
|
||||||
├── ChatExporter.swift — Export conversations to Markdown or RTF
|
├── ChatExporter.swift — Export conversations to Markdown or RTF
|
||||||
├── FocusedValues.swift — FocusedValue keys for menu bar integration
|
├── 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)
|
project.yml — xcodegen project spec (dependencies, settings, deployment target)
|
||||||
build.sh — One-command build script (xcodegen + xcodebuild)
|
build.sh — One-command build script (xcodegen + xcodebuild)
|
||||||
|
|||||||
Reference in New Issue
Block a user