feat: scene management for RP settings
This commit is contained in:
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -52,8 +60,12 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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 = "<group>";
|
||||
@@ -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 = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
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 {
|
||||
@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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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 {
|
||||
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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
@@ -27,6 +27,10 @@ struct StatusBarView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Label(viewModel.activeSceneName, systemImage: "theatermasks")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// GPU memory
|
||||
|
||||
10
README.md
10
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)
|
||||
|
||||
Reference in New Issue
Block a user