feat: scene management for RP settings

This commit is contained in:
2026-03-18 14:57:29 +01:00
parent ed1c91cd2b
commit 09b94b32d0
14 changed files with 635 additions and 4 deletions

View File

@@ -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 */,

View 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])
}
}
}

View File

@@ -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

View File

@@ -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
}

View 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")
}

View File

@@ -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"

View File

@@ -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).

View 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
}
}

View 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
}
}
)
}
}

View File

@@ -0,0 +1,5 @@
import Foundation
enum SceneManagementWindow {
static let windowID = "scene-manager"
}

View 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"
}
}

View File

@@ -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")

View File

@@ -27,6 +27,10 @@ struct StatusBarView: View {
.foregroundStyle(.secondary)
}
Label(viewModel.activeSceneName, systemImage: "theatermasks")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
// GPU memory

View File

@@ -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)