feat: document saving/loading
This commit is contained in:
@@ -11,10 +11,12 @@
|
||||
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 */; };
|
||||
1A8833E3CCD3289C95E282A2 /* ChatDocumentManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1607BDDE53C575627DCC6896 /* ChatDocumentManifest.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 */; };
|
||||
2E3A02DF9C6A5109E532D5E2 /* ChatDocumentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */; };
|
||||
4158FA884D981D73288FB74C /* SaveChatCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */; };
|
||||
4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E6AD02CDF23BDAB64700A7 /* ChatInputView.swift */; };
|
||||
4DC033E45880B2948B47DEB1 /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF518FEBF3A38E830E3CE1A5 /* FocusedValues.swift */; };
|
||||
@@ -29,10 +31,12 @@
|
||||
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 */; };
|
||||
B13FFE238613BFBFC72E0CC8 /* ChatDocumentMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */; };
|
||||
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 */; };
|
||||
C34F02550C584BB2547F0F6C /* ChatDocumentPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.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 */; };
|
||||
@@ -46,15 +50,19 @@
|
||||
/* 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>"; };
|
||||
1607BDDE53C575627DCC6896 /* ChatDocumentManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentManifest.swift; sourceTree = "<group>"; };
|
||||
16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolPromptBuilder.swift; sourceTree = "<group>"; };
|
||||
24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentMigration.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>"; };
|
||||
386CD08DC6338F42460DFBE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; 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>"; };
|
||||
4147321383E94E9F17A0154E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
4239CFF94B819C35A8D4D617 /* MonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorView.swift; sourceTree = "<group>"; };
|
||||
6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentPackage.swift; sourceTree = "<group>"; };
|
||||
6EE59189918D06B8D2F588FC /* MLXServer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLXServer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
922CBDC9206737BD04AF2874 /* ModelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelManager.swift; sourceTree = "<group>"; };
|
||||
944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
@@ -68,6 +76,7 @@
|
||||
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>"; };
|
||||
D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentController.swift; sourceTree = "<group>"; };
|
||||
D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalModelResolver.swift; sourceTree = "<group>"; };
|
||||
D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExporter.swift; sourceTree = "<group>"; };
|
||||
DB1A5E8B1C9F2BC4D262C53A /* ChatMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagesView.swift; sourceTree = "<group>"; };
|
||||
@@ -104,6 +113,17 @@
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
08DD6175C845EFB9638A0688 /* Documents */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D5C1FCEFEA72B9ABB87FB20E /* ChatDocumentController.swift */,
|
||||
1607BDDE53C575627DCC6896 /* ChatDocumentManifest.swift */,
|
||||
24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */,
|
||||
6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */,
|
||||
);
|
||||
path = Documents;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
652987C2A419DBFC79E32CDE /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -117,9 +137,11 @@
|
||||
children = (
|
||||
B629DA084A9A40E54F8EA5FA /* Assets.xcassets */,
|
||||
944C699FBB76C734C9DF2F2E /* ContentView.swift */,
|
||||
386CD08DC6338F42460DFBE2 /* Info.plist */,
|
||||
3AF462805202797F61422AEE /* MLXServer.entitlements */,
|
||||
C67742651DB486871CEF1612 /* MLXServerApp.swift */,
|
||||
B459409ED6FD8797FDD81E94 /* Commands */,
|
||||
08DD6175C845EFB9638A0688 /* Documents */,
|
||||
BD0E350482D91238B4B59721 /* Models */,
|
||||
E13C1AAA0C49D0ED85EFD94D /* Server */,
|
||||
05B1BAE308E64D2FB2E73823 /* Utilities */,
|
||||
@@ -273,6 +295,10 @@
|
||||
files = (
|
||||
D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */,
|
||||
50DD129CCF2843482DEC3B96 /* APIServer.swift in Sources */,
|
||||
2E3A02DF9C6A5109E532D5E2 /* ChatDocumentController.swift in Sources */,
|
||||
1A8833E3CCD3289C95E282A2 /* ChatDocumentManifest.swift in Sources */,
|
||||
B13FFE238613BFBFC72E0CC8 /* ChatDocumentMigration.swift in Sources */,
|
||||
C34F02550C584BB2547F0F6C /* ChatDocumentPackage.swift in Sources */,
|
||||
29879D696584B96CC56560DF /* ChatExporter.swift in Sources */,
|
||||
4CB13DC1AC7A500DDBB443EC /* ChatInputView.swift in Sources */,
|
||||
FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */,
|
||||
@@ -434,9 +460,8 @@
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MLXServer/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -459,9 +484,8 @@
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MLXServer/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Adds "Export Chat…" to the File menu.
|
||||
struct SaveChatCommands: Commands {
|
||||
@FocusedValue(\.newChatAction) private var newChatAction
|
||||
@FocusedValue(\.openChatAction) private var openChatAction
|
||||
@FocusedValue(\.saveChatAction) private var saveChatAction
|
||||
@FocusedValue(\.saveChatAsAction) private var saveChatAsAction
|
||||
@FocusedValue(\.revertChatAction) private var revertChatAction
|
||||
@FocusedValue(\.exportChatAction) private var exportChatAction
|
||||
|
||||
var body: some Commands {
|
||||
CommandGroup(after: .saveItem) {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") {
|
||||
newChatAction?()
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
|
||||
Button("Open Chat…") {
|
||||
openChatAction?()
|
||||
}
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
.disabled(openChatAction == nil)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Save Chat") {
|
||||
saveChatAction?()
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.disabled(saveChatAction == nil)
|
||||
|
||||
Button("Save Chat As…") {
|
||||
saveChatAsAction?()
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
.disabled(saveChatAsAction == nil)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Revert To Saved") {
|
||||
revertChatAction?()
|
||||
}
|
||||
.disabled(revertChatAction == nil)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Export Chat…") {
|
||||
exportChatAction?()
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
.keyboardShortcut("e", modifiers: [.command, .shift])
|
||||
.disabled(exportChatAction == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(ChatDocumentController.self) private var documentController
|
||||
@Environment(ModelManager.self) private var modelManager
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(SceneStore.self) private var sceneStore
|
||||
@@ -10,11 +12,16 @@ struct ContentView: View {
|
||||
@State private var showMonitor = false
|
||||
@State private var showScenePicker = false
|
||||
@State private var exportDocument: ChatExportDocument?
|
||||
@State private var documentErrorMessage: String?
|
||||
@State private var exportErrorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
.navigationTitle(modelManager.currentModel?.displayName ?? "MLX Server")
|
||||
exportedContent
|
||||
}
|
||||
|
||||
private var lifecycleContent: some View {
|
||||
AnyView(mainContent)
|
||||
.navigationTitle(navigationTitleText)
|
||||
.onAppear {
|
||||
if chatVM == nil {
|
||||
chatVM = ChatViewModel(modelManager: modelManager)
|
||||
@@ -23,17 +30,30 @@ struct ContentView: View {
|
||||
chatVM?.startAPIServer()
|
||||
}
|
||||
}
|
||||
|
||||
processPendingOpenRequests()
|
||||
}
|
||||
.onChange(of: modelManager.currentModel) {
|
||||
chatVM?.handleModelChange()
|
||||
chatVM?.markDirtyIfNeeded()
|
||||
// Persist last used model
|
||||
if let id = modelManager.currentModel?.id {
|
||||
Preferences.lastModelId = id
|
||||
}
|
||||
}
|
||||
.onChange(of: chatVM?.inputText ?? "") {
|
||||
chatVM?.markDirtyIfNeeded()
|
||||
}
|
||||
.onChange(of: modelManager.errorMessage) {
|
||||
showLoadError = modelManager.errorMessage != nil
|
||||
}
|
||||
.onChange(of: documentController.openRequestNonce) {
|
||||
processPendingOpenRequests()
|
||||
}
|
||||
}
|
||||
|
||||
private var alertContent: some View {
|
||||
AnyView(lifecycleContent)
|
||||
.alert("Model Error", isPresented: $showLoadError) {
|
||||
Button("Retry") {
|
||||
if let config = modelManager.currentModel ?? ModelConfig.availableModels.first {
|
||||
@@ -46,6 +66,13 @@ struct ContentView: View {
|
||||
} message: {
|
||||
Text(modelManager.errorMessage ?? "Unknown error loading model.")
|
||||
}
|
||||
.alert("Document Error", isPresented: documentErrorBinding) {
|
||||
Button("OK", role: .cancel) {
|
||||
documentErrorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(documentErrorMessage ?? "Unknown document error.")
|
||||
}
|
||||
.alert("Export Failed", isPresented: exportErrorBinding) {
|
||||
Button("OK", role: .cancel) {
|
||||
exportErrorMessage = nil
|
||||
@@ -53,6 +80,10 @@ struct ContentView: View {
|
||||
} message: {
|
||||
Text(exportErrorMessage ?? "Unknown export error.")
|
||||
}
|
||||
}
|
||||
|
||||
private var exportedContent: some View {
|
||||
AnyView(alertContent)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
ModelPickerView()
|
||||
@@ -65,6 +96,11 @@ struct ContentView: View {
|
||||
.background {
|
||||
modelSwitchShortcuts
|
||||
}
|
||||
.focusedSceneValue(\.newChatAction, NewChatAction(perform: beginNewChat))
|
||||
.focusedSceneValue(\.openChatAction, OpenChatAction(perform: beginOpenDocument))
|
||||
.focusedSceneValue(\.saveChatAction, SaveChatAction(perform: saveCurrentDocument))
|
||||
.focusedSceneValue(\.saveChatAsAction, SaveChatAsAction(perform: saveCurrentDocumentAs))
|
||||
.focusedSceneValue(\.revertChatAction, RevertChatAction(perform: beginRevertToSaved))
|
||||
.focusedSceneValue(\.exportChatAction, ExportChatAction(perform: beginExport))
|
||||
.fileExporter(
|
||||
isPresented: Binding(
|
||||
@@ -87,6 +123,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationTitleText: String {
|
||||
if let title = chatVM?.windowTitle {
|
||||
return title
|
||||
}
|
||||
return modelManager.currentModel?.displayName ?? "MLX Server"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
ZStack {
|
||||
@@ -145,7 +188,7 @@ struct ContentView: View {
|
||||
|
||||
// New conversation
|
||||
Button {
|
||||
showScenePicker = true
|
||||
beginNewChat()
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "plus.message")
|
||||
}
|
||||
@@ -157,11 +200,11 @@ struct ContentView: View {
|
||||
currentModelName: modelManager.currentModel?.displayName,
|
||||
onSelectNeutral: {
|
||||
showScenePicker = false
|
||||
Task { await chatVM?.startNewConversation(scene: nil) }
|
||||
startConversation(scene: nil)
|
||||
},
|
||||
onSelectScene: { scene in
|
||||
showScenePicker = false
|
||||
Task { await chatVM?.startNewConversation(scene: scene) }
|
||||
startConversation(scene: scene)
|
||||
},
|
||||
onManageScenes: {
|
||||
showScenePicker = false
|
||||
@@ -196,6 +239,10 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var exportDefaultFilename: String {
|
||||
if let currentDocumentURL = chatVM?.currentDocumentURL {
|
||||
return currentDocumentURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd-HHmm"
|
||||
return "chat-\(formatter.string(from: .now))"
|
||||
@@ -208,6 +255,145 @@ struct ContentView: View {
|
||||
modelName: modelManager.currentModel?.displayName
|
||||
)
|
||||
}
|
||||
|
||||
private var documentDefaultFilename: String {
|
||||
if let currentDocumentURL = chatVM?.currentDocumentURL {
|
||||
return currentDocumentURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
return exportDefaultFilename
|
||||
}
|
||||
|
||||
private var documentErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { documentErrorMessage != nil },
|
||||
set: {
|
||||
if !$0 {
|
||||
documentErrorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func beginNewChat() {
|
||||
showScenePicker = true
|
||||
}
|
||||
|
||||
private func startConversation(scene: ChatScene?) {
|
||||
guard confirmDiscardUnsavedChanges(
|
||||
title: "Discard Unsaved Changes?",
|
||||
message: "Starting a new chat will replace the current conversation."
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await chatVM?.startNewConversation(scene: scene)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginOpenDocument() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.mlxChatDocument]
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.treatsFilePackagesAsDirectories = false
|
||||
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
Task {
|
||||
await openDocument(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentDocument() {
|
||||
guard let chatVM else { return }
|
||||
|
||||
if let currentDocumentURL = chatVM.currentDocumentURL {
|
||||
do {
|
||||
try chatVM.saveDocument(to: currentDocumentURL)
|
||||
} catch {
|
||||
documentErrorMessage = error.localizedDescription
|
||||
}
|
||||
} else {
|
||||
saveCurrentDocumentAs()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentDocumentAs() {
|
||||
guard let chatVM else { return }
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.mlxChatDocument]
|
||||
panel.canCreateDirectories = true
|
||||
panel.isExtensionHidden = false
|
||||
panel.nameFieldStringValue = documentDefaultFilename
|
||||
|
||||
guard panel.runModal() == .OK, let panelURL = panel.url else { return }
|
||||
|
||||
let saveURL: URL
|
||||
if panelURL.pathExtension.lowercased() == "mlxchat" {
|
||||
saveURL = panelURL
|
||||
} else {
|
||||
saveURL = panelURL.appendingPathExtension("mlxchat")
|
||||
}
|
||||
|
||||
do {
|
||||
try chatVM.saveDocument(to: saveURL)
|
||||
} catch {
|
||||
documentErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func beginRevertToSaved() {
|
||||
guard let currentDocumentURL = chatVM?.currentDocumentURL else { return }
|
||||
guard confirmDiscardUnsavedChanges(
|
||||
title: "Revert To Saved Version?",
|
||||
message: "All unsaved changes in the current chat will be lost."
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await openDocument(at: currentDocumentURL, skipUnsavedCheck: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func processPendingOpenRequests() {
|
||||
guard chatVM != nil else { return }
|
||||
|
||||
Task {
|
||||
while let url = documentController.consumeNextOpenRequest() {
|
||||
await openDocument(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openDocument(at url: URL, skipUnsavedCheck: Bool = false) async {
|
||||
if !skipUnsavedCheck {
|
||||
let shouldContinue = confirmDiscardUnsavedChanges(
|
||||
title: "Discard Unsaved Changes?",
|
||||
message: "Opening another chat will replace the current conversation."
|
||||
)
|
||||
guard shouldContinue else { return }
|
||||
}
|
||||
|
||||
do {
|
||||
try await chatVM?.loadDocument(from: url)
|
||||
} catch {
|
||||
documentErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmDiscardUnsavedChanges(title: String, message: String) -> Bool {
|
||||
guard chatVM?.hasUnsavedChanges == true else { return true }
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: "Discard Changes")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
return alert.runModal() == .alertFirstButtonReturn
|
||||
}
|
||||
}
|
||||
|
||||
/// The main chat layout: messages + input area + status bar.
|
||||
|
||||
25
MLXServer/Documents/ChatDocumentController.swift
Normal file
25
MLXServer/Documents/ChatDocumentController.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ChatDocumentController {
|
||||
static let shared = ChatDocumentController()
|
||||
|
||||
private(set) var pendingOpenURLs: [URL] = []
|
||||
private(set) var openRequestNonce = UUID()
|
||||
|
||||
func enqueueOpenRequests(_ urls: [URL]) {
|
||||
guard !urls.isEmpty else { return }
|
||||
pendingOpenURLs.append(contentsOf: urls)
|
||||
openRequestNonce = UUID()
|
||||
}
|
||||
|
||||
func consumeNextOpenRequest() -> URL? {
|
||||
guard !pendingOpenURLs.isEmpty else { return nil }
|
||||
return pendingOpenURLs.removeFirst()
|
||||
}
|
||||
|
||||
var hasPendingOpenRequests: Bool {
|
||||
!pendingOpenURLs.isEmpty
|
||||
}
|
||||
}
|
||||
73
MLXServer/Documents/ChatDocumentManifest.swift
Normal file
73
MLXServer/Documents/ChatDocumentManifest.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
struct ChatDocumentManifest: Codable {
|
||||
var schemaVersion: Int
|
||||
var documentId: UUID
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var appVersion: String
|
||||
var model: StoredModelInfo?
|
||||
var settings: StoredChatSettings
|
||||
var messages: [StoredChatMessage]
|
||||
var uiState: StoredChatUIState
|
||||
|
||||
static let currentSchemaVersion = 1
|
||||
|
||||
struct StoredModelInfo: Codable, Hashable {
|
||||
var id: String
|
||||
var displayName: String
|
||||
var repoId: String
|
||||
}
|
||||
|
||||
struct StoredChatSettings: Codable, Hashable {
|
||||
var systemPrompt: String
|
||||
var thinkingEnabled: Bool
|
||||
var temperature: Double
|
||||
}
|
||||
|
||||
struct StoredChatUIState: Codable, Hashable {
|
||||
var draftInput: String
|
||||
var scrollAnchorMessageId: UUID?
|
||||
}
|
||||
|
||||
struct StoredChatMessage: Codable, Hashable, Identifiable {
|
||||
enum Role: String, Codable {
|
||||
case system
|
||||
case user
|
||||
case assistant
|
||||
}
|
||||
|
||||
enum StreamingState: String, Codable {
|
||||
case streaming
|
||||
case completed
|
||||
}
|
||||
|
||||
var id: UUID
|
||||
var role: Role
|
||||
var createdAt: Date
|
||||
var content: String
|
||||
var rawContent: String
|
||||
var thinkingContent: String
|
||||
var streamingState: StreamingState
|
||||
var attachments: [StoredAttachment]
|
||||
}
|
||||
|
||||
struct StoredAttachment: Codable, Hashable, Identifiable {
|
||||
var id: UUID
|
||||
var type: String
|
||||
var relativePath: String
|
||||
var mimeType: String
|
||||
var pixelWidth: Int?
|
||||
var pixelHeight: Int?
|
||||
var sha256: String
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatDocumentSnapshot: Codable, Hashable {
|
||||
var documentId: UUID
|
||||
var createdAt: Date
|
||||
var model: ChatDocumentManifest.StoredModelInfo?
|
||||
var settings: ChatDocumentManifest.StoredChatSettings
|
||||
var messages: [ChatDocumentManifest.StoredChatMessage]
|
||||
var uiState: ChatDocumentManifest.StoredChatUIState
|
||||
}
|
||||
19
MLXServer/Documents/ChatDocumentMigration.swift
Normal file
19
MLXServer/Documents/ChatDocumentMigration.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatDocumentMigration {
|
||||
private struct ManifestEnvelope: Decodable {
|
||||
let schemaVersion: Int
|
||||
}
|
||||
|
||||
static func loadManifest(from data: Data) throws -> ChatDocumentManifest {
|
||||
let decoder = JSONDecoder.chatDocumentDecoder
|
||||
let envelope = try decoder.decode(ManifestEnvelope.self, from: data)
|
||||
|
||||
switch envelope.schemaVersion {
|
||||
case 1:
|
||||
return try decoder.decode(ChatDocumentManifest.self, from: data)
|
||||
default:
|
||||
throw ChatDocumentError.unsupportedSchemaVersion(envelope.schemaVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
146
MLXServer/Documents/ChatDocumentPackage.swift
Normal file
146
MLXServer/Documents/ChatDocumentPackage.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension UTType {
|
||||
static let mlxChatDocument = UTType(exportedAs: "de.rfc1437.mlxserver.chat", conformingTo: .package)
|
||||
}
|
||||
|
||||
enum ChatDocumentError: LocalizedError {
|
||||
case invalidPackage
|
||||
case missingManifest
|
||||
case missingAttachment(String)
|
||||
case invalidAttachmentData(String)
|
||||
case unsupportedSchemaVersion(Int)
|
||||
case saveWhileGenerating
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidPackage:
|
||||
return "The selected file is not a valid MLX Server chat document."
|
||||
case .missingManifest:
|
||||
return "The chat document is missing manifest.json."
|
||||
case .missingAttachment(let path):
|
||||
return "The chat document is missing attachment \(path)."
|
||||
case .invalidAttachmentData(let path):
|
||||
return "The attachment \(path) could not be decoded as an image."
|
||||
case .unsupportedSchemaVersion(let version):
|
||||
return "This chat document uses unsupported schema version \(version)."
|
||||
case .saveWhileGenerating:
|
||||
return "Stop generation before saving this chat document."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatDocumentPackage: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.mlxChatDocument] }
|
||||
static var writableContentTypes: [UTType] { [.mlxChatDocument] }
|
||||
|
||||
let manifest: ChatDocumentManifest
|
||||
let attachmentContents: [String: Data]
|
||||
|
||||
init(manifest: ChatDocumentManifest, attachmentContents: [String: Data]) {
|
||||
self.manifest = manifest
|
||||
self.attachmentContents = attachmentContents
|
||||
}
|
||||
|
||||
init(contentsOf url: URL) throws {
|
||||
let wrapper = try FileWrapper(url: url, options: .immediate)
|
||||
try self.init(rootWrapper: wrapper)
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
try self.init(rootWrapper: configuration.file)
|
||||
}
|
||||
|
||||
private init(rootWrapper: FileWrapper) throws {
|
||||
guard let fileWrappers = rootWrapper.fileWrappers else {
|
||||
throw ChatDocumentError.invalidPackage
|
||||
}
|
||||
|
||||
guard let manifestWrapper = fileWrappers["manifest.json"],
|
||||
let manifestData = manifestWrapper.regularFileContents else {
|
||||
throw ChatDocumentError.missingManifest
|
||||
}
|
||||
|
||||
let manifest = try ChatDocumentMigration.loadManifest(from: manifestData)
|
||||
var attachmentContents: [String: Data] = [:]
|
||||
|
||||
for message in manifest.messages {
|
||||
for attachment in message.attachments {
|
||||
if attachmentContents[attachment.relativePath] != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
let pathComponents = attachment.relativePath.split(separator: "/").map(String.init)
|
||||
guard let attachmentData = Self.data(at: pathComponents, from: fileWrappers) else {
|
||||
throw ChatDocumentError.missingAttachment(attachment.relativePath)
|
||||
}
|
||||
attachmentContents[attachment.relativePath] = attachmentData
|
||||
}
|
||||
}
|
||||
|
||||
self.manifest = manifest
|
||||
self.attachmentContents = attachmentContents
|
||||
}
|
||||
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
try makeFileWrapper()
|
||||
}
|
||||
|
||||
func write(to url: URL) throws {
|
||||
let wrapper = try makeFileWrapper()
|
||||
let fileManager = FileManager.default
|
||||
let options = FileWrapper.WritingOptions.atomic
|
||||
if fileManager.fileExists(atPath: url.path) {
|
||||
try wrapper.write(to: url, options: options, originalContentsURL: url)
|
||||
} else {
|
||||
try wrapper.write(to: url, options: options, originalContentsURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeFileWrapper() throws -> FileWrapper {
|
||||
var wrappers: [String: FileWrapper] = [:]
|
||||
let manifestData = try JSONEncoder.chatDocumentEncoder.encode(manifest)
|
||||
wrappers["manifest.json"] = FileWrapper(regularFileWithContents: manifestData)
|
||||
|
||||
var attachmentWrappers: [String: FileWrapper] = [:]
|
||||
for (relativePath, data) in attachmentContents {
|
||||
let pathComponents = relativePath.split(separator: "/").map(String.init)
|
||||
guard pathComponents.count == 2, pathComponents.first == "attachments" else { continue }
|
||||
attachmentWrappers[pathComponents[1]] = FileWrapper(regularFileWithContents: data)
|
||||
}
|
||||
|
||||
wrappers["attachments"] = FileWrapper(directoryWithFileWrappers: attachmentWrappers)
|
||||
return FileWrapper(directoryWithFileWrappers: wrappers)
|
||||
}
|
||||
|
||||
private static func data(at pathComponents: [String], from wrappers: [String: FileWrapper]) -> Data? {
|
||||
guard let first = pathComponents.first else { return nil }
|
||||
guard let wrapper = wrappers[first] else { return nil }
|
||||
|
||||
if pathComponents.count == 1 {
|
||||
return wrapper.regularFileContents
|
||||
}
|
||||
|
||||
guard let childWrappers = wrapper.fileWrappers else { return nil }
|
||||
return data(at: Array(pathComponents.dropFirst()), from: childWrappers)
|
||||
}
|
||||
}
|
||||
|
||||
private extension JSONEncoder {
|
||||
static var chatDocumentEncoder: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONDecoder {
|
||||
static var chatDocumentDecoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
66
MLXServer/Info.plist
Normal file
66
MLXServer/Info.plist
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>MLX Server Chat Document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSTypeIsPackage</key>
|
||||
<true/>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>de.rfc1437.mlxserver.chat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string></string>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>com.apple.package</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>MLX Server Chat Document</string>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>de.rfc1437.mlxserver.chat</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>mlxchat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,8 +1,18 @@
|
||||
import SwiftUI
|
||||
import MLX
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
Task { @MainActor in
|
||||
ChatDocumentController.shared.enqueueOpenRequests(urls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct MLXServerApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var documentController = ChatDocumentController.shared
|
||||
@State private var modelManager = ModelManager()
|
||||
@State private var sceneStore = SceneStore()
|
||||
|
||||
@@ -13,9 +23,11 @@ struct MLXServerApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(documentController)
|
||||
.environment(modelManager)
|
||||
.environment(sceneStore)
|
||||
.task {
|
||||
guard !documentController.hasPendingOpenRequests else { return }
|
||||
// Auto-load: configured default → last used → built-in default
|
||||
let modelId = Preferences.defaultModelId ?? Preferences.lastModelId ?? ModelConfig.default.id
|
||||
if let config = ModelConfig.availableModels.first(where: { $0.id == modelId }) {
|
||||
|
||||
@@ -1,12 +1,89 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import MLXLMCommon
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ChatAttachment: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let data: Data
|
||||
let mimeType: String
|
||||
let pixelWidth: Int?
|
||||
let pixelHeight: Int?
|
||||
let sha256: String
|
||||
|
||||
init?(
|
||||
id: UUID = UUID(),
|
||||
data: Data,
|
||||
mimeType: String,
|
||||
pixelWidth: Int? = nil,
|
||||
pixelHeight: Int? = nil,
|
||||
sha256: String? = nil
|
||||
) {
|
||||
guard NSImage(data: data) != nil else { return nil }
|
||||
|
||||
self.id = id
|
||||
self.data = data
|
||||
self.mimeType = mimeType
|
||||
|
||||
let dimensions = Self.resolveDimensions(from: data)
|
||||
self.pixelWidth = pixelWidth ?? dimensions.width
|
||||
self.pixelHeight = pixelHeight ?? dimensions.height
|
||||
self.sha256 = sha256 ?? Self.sha256Hex(for: data)
|
||||
}
|
||||
|
||||
init?(id: UUID = UUID(), image: NSImage) {
|
||||
guard let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.init(
|
||||
id: id,
|
||||
data: pngData,
|
||||
mimeType: "image/png",
|
||||
pixelWidth: bitmap.pixelsWide,
|
||||
pixelHeight: bitmap.pixelsHigh
|
||||
)
|
||||
}
|
||||
|
||||
var fileExtension: String {
|
||||
UTType(mimeType: mimeType)?.preferredFilenameExtension ?? "bin"
|
||||
}
|
||||
|
||||
var image: NSImage? {
|
||||
NSImage(data: data)
|
||||
}
|
||||
|
||||
var userInputImage: UserInput.Image? {
|
||||
guard let image,
|
||||
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
return .ciImage(CIImage(cgImage: cgImage))
|
||||
}
|
||||
|
||||
private static func resolveDimensions(from data: Data) -> (width: Int?, height: Int?) {
|
||||
guard let image = NSImage(data: data),
|
||||
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
return (cgImage.width, cgImage.height)
|
||||
}
|
||||
|
||||
private static func sha256Hex(for data: Data) -> String {
|
||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message in the chat conversation.
|
||||
struct ChatMessage: Identifiable {
|
||||
let id = UUID()
|
||||
let id: UUID
|
||||
let role: Role
|
||||
var content: String
|
||||
var images: [NSImage]
|
||||
var attachments: [ChatAttachment]
|
||||
var isStreaming: Bool
|
||||
let timestamp: Date
|
||||
|
||||
@@ -26,43 +103,53 @@ struct ChatMessage: Identifiable {
|
||||
case assistant
|
||||
}
|
||||
|
||||
init(role: Role, content: String, images: [NSImage] = [], isStreaming: Bool = false) {
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
role: Role,
|
||||
content: String,
|
||||
attachments: [ChatAttachment] = [],
|
||||
isStreaming: Bool = false,
|
||||
timestamp: Date = Date(),
|
||||
rawContent: String? = nil,
|
||||
thinkingContent: String? = nil,
|
||||
isThinking: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.rawContent = content
|
||||
self.images = images
|
||||
self.rawContent = rawContent ?? content
|
||||
self.attachments = attachments
|
||||
self.isStreaming = isStreaming
|
||||
self.timestamp = Date()
|
||||
}
|
||||
}
|
||||
self.timestamp = timestamp
|
||||
self.thinkingContent = thinkingContent ?? ""
|
||||
self.isThinking = isThinking
|
||||
|
||||
/// Observable conversation state holding all messages.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class Conversation {
|
||||
var messages: [ChatMessage] = []
|
||||
|
||||
func addUserMessage(_ text: String, images: [NSImage] = []) {
|
||||
messages.append(ChatMessage(role: .user, content: text, images: images))
|
||||
if role == .assistant, rawContent != nil {
|
||||
applyParsedContent(Self.parseAssistantContent(self.rawContent))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an empty assistant message (to be filled via streaming) and returns its index.
|
||||
func addAssistantMessage() -> Int {
|
||||
let msg = ChatMessage(role: .assistant, content: "", isStreaming: true)
|
||||
messages.append(msg)
|
||||
return messages.count - 1
|
||||
var sessionContent: String {
|
||||
role == .assistant ? rawContent : content
|
||||
}
|
||||
|
||||
/// Appends a text chunk to the assistant message at the given index.
|
||||
/// Handles `<think>...</think>` tags by routing content to `thinkingContent` vs `content`.
|
||||
func appendToMessage(at index: Int, chunk: String) {
|
||||
guard index < messages.count else { return }
|
||||
messages[index].rawContent += chunk
|
||||
mutating func refreshAssistantContentFromRaw() {
|
||||
applyParsedContent(Self.parseAssistantContent(rawContent))
|
||||
}
|
||||
|
||||
// Parse the full raw content to separate thinking from response.
|
||||
// This is simpler and more robust than incremental parsing since
|
||||
// tag boundaries can split across chunks.
|
||||
let raw = messages[index].rawContent
|
||||
private mutating func applyParsedContent(_ parsed: ParsedAssistantContent) {
|
||||
thinkingContent = parsed.thinking
|
||||
content = parsed.visible
|
||||
isThinking = parsed.isInThinkingBlock
|
||||
}
|
||||
|
||||
private struct ParsedAssistantContent {
|
||||
let visible: String
|
||||
let thinking: String
|
||||
let isInThinkingBlock: Bool
|
||||
}
|
||||
|
||||
private static func parseAssistantContent(_ raw: String) -> ParsedAssistantContent {
|
||||
var thinking = ""
|
||||
var visible = ""
|
||||
var isInThink = false
|
||||
@@ -75,7 +162,6 @@ final class Conversation {
|
||||
scanner = scanner[endRange.upperBound...]
|
||||
isInThink = false
|
||||
} else {
|
||||
// Still inside thinking — all remaining text is thinking
|
||||
thinking += String(scanner)
|
||||
break
|
||||
}
|
||||
@@ -91,9 +177,38 @@ final class Conversation {
|
||||
}
|
||||
}
|
||||
|
||||
messages[index].thinkingContent = thinking.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
messages[index].content = visible.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
messages[index].isThinking = isInThink
|
||||
return ParsedAssistantContent(
|
||||
visible: visible.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
thinking: thinking.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
isInThinkingBlock: isInThink
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable conversation state holding all messages.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class Conversation {
|
||||
var messages: [ChatMessage] = []
|
||||
|
||||
func addUserMessage(_ text: String, images: [NSImage] = []) {
|
||||
let attachments = images.compactMap { ChatAttachment(image: $0) }
|
||||
messages.append(ChatMessage(role: .user, content: text, attachments: attachments))
|
||||
}
|
||||
|
||||
/// Adds an empty assistant message (to be filled via streaming) and returns its index.
|
||||
func addAssistantMessage() -> Int {
|
||||
let msg = ChatMessage(role: .assistant, content: "", isStreaming: true)
|
||||
messages.append(msg)
|
||||
return messages.count - 1
|
||||
}
|
||||
|
||||
/// Appends a text chunk to the assistant message at the given index.
|
||||
/// Handles `<think>...</think>` tags by routing content to `thinkingContent` vs `content`.
|
||||
func appendToMessage(at index: Int, chunk: String) {
|
||||
guard index < messages.count else { return }
|
||||
messages[index].rawContent += chunk
|
||||
messages[index].refreshAssistantContentFromRaw()
|
||||
}
|
||||
|
||||
/// Marks the assistant message at the given index as done streaming.
|
||||
@@ -106,4 +221,8 @@ final class Conversation {
|
||||
func clear() {
|
||||
messages.removeAll()
|
||||
}
|
||||
|
||||
func replaceMessages(_ restoredMessages: [ChatMessage]) {
|
||||
messages = restoredMessages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ExportChatAction {
|
||||
struct ChatCommandAction {
|
||||
let perform: () -> Void
|
||||
|
||||
func callAsFunction() {
|
||||
@@ -8,14 +8,65 @@ struct ExportChatAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Focused value key for triggering chat export from the menu bar.
|
||||
typealias ExportChatAction = ChatCommandAction
|
||||
typealias NewChatAction = ChatCommandAction
|
||||
typealias OpenChatAction = ChatCommandAction
|
||||
typealias SaveChatAction = ChatCommandAction
|
||||
typealias SaveChatAsAction = ChatCommandAction
|
||||
typealias RevertChatAction = ChatCommandAction
|
||||
|
||||
struct FocusedExportActionKey: FocusedValueKey {
|
||||
typealias Value = ExportChatAction
|
||||
}
|
||||
|
||||
struct FocusedNewChatActionKey: FocusedValueKey {
|
||||
typealias Value = NewChatAction
|
||||
}
|
||||
|
||||
struct FocusedOpenChatActionKey: FocusedValueKey {
|
||||
typealias Value = OpenChatAction
|
||||
}
|
||||
|
||||
struct FocusedSaveChatActionKey: FocusedValueKey {
|
||||
typealias Value = SaveChatAction
|
||||
}
|
||||
|
||||
struct FocusedSaveChatAsActionKey: FocusedValueKey {
|
||||
typealias Value = SaveChatAsAction
|
||||
}
|
||||
|
||||
struct FocusedRevertChatActionKey: FocusedValueKey {
|
||||
typealias Value = RevertChatAction
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var exportChatAction: ExportChatAction? {
|
||||
get { self[FocusedExportActionKey.self] }
|
||||
set { self[FocusedExportActionKey.self] = newValue }
|
||||
}
|
||||
|
||||
var newChatAction: NewChatAction? {
|
||||
get { self[FocusedNewChatActionKey.self] }
|
||||
set { self[FocusedNewChatActionKey.self] = newValue }
|
||||
}
|
||||
|
||||
var openChatAction: OpenChatAction? {
|
||||
get { self[FocusedOpenChatActionKey.self] }
|
||||
set { self[FocusedOpenChatActionKey.self] = newValue }
|
||||
}
|
||||
|
||||
var saveChatAction: SaveChatAction? {
|
||||
get { self[FocusedSaveChatActionKey.self] }
|
||||
set { self[FocusedSaveChatActionKey.self] = newValue }
|
||||
}
|
||||
|
||||
var saveChatAsAction: SaveChatAsAction? {
|
||||
get { self[FocusedSaveChatAsActionKey.self] }
|
||||
set { self[FocusedSaveChatAsActionKey.self] = newValue }
|
||||
}
|
||||
|
||||
var revertChatAction: RevertChatAction? {
|
||||
get { self[FocusedRevertChatActionKey.self] }
|
||||
set { self[FocusedRevertChatActionKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import MLX
|
||||
import MLXLMCommon
|
||||
@@ -12,13 +13,22 @@ final class ChatViewModel {
|
||||
var inputText = ""
|
||||
var attachedImages: [NSImage] = []
|
||||
var activeScene: ChatScene?
|
||||
var currentDocumentURL: URL?
|
||||
var hasUnsavedChanges = false
|
||||
var isGenerating = false
|
||||
var tokensPerSecond: Double = 0
|
||||
var promptTokens: Int = 0
|
||||
var generationTokens: Int = 0
|
||||
|
||||
private(set) var lastSavedSnapshotHash: String?
|
||||
|
||||
private var generationTask: Task<Void, Never>?
|
||||
private var chatSession: ChatSession?
|
||||
private var documentId = UUID()
|
||||
private var documentCreatedAt = Date()
|
||||
private var documentSystemPromptOverride: String?
|
||||
private var documentThinkingOverride: Bool?
|
||||
private var documentTemperature = 0.7
|
||||
|
||||
let modelManager: ModelManager
|
||||
let apiServer = APIServer()
|
||||
@@ -31,6 +41,14 @@ final class ChatViewModel {
|
||||
activeScene?.displayName ?? "Neutral"
|
||||
}
|
||||
|
||||
var documentDisplayName: String {
|
||||
currentDocumentURL?.deletingPathExtension().lastPathComponent ?? "Untitled Chat"
|
||||
}
|
||||
|
||||
var windowTitle: String {
|
||||
hasUnsavedChanges ? "\(documentDisplayName) *" : documentDisplayName
|
||||
}
|
||||
|
||||
/// Ensure a ChatSession exists for the current model.
|
||||
private func ensureSession() {
|
||||
guard let container = modelManager.modelContainer else { return }
|
||||
@@ -38,19 +56,35 @@ final class ChatViewModel {
|
||||
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
|
||||
let thinkingContext: [String: any Sendable]? = effectiveThinkingEnabled
|
||||
? nil
|
||||
: ["enable_thinking": false]
|
||||
chatSession = ChatSession(
|
||||
container,
|
||||
instructions: systemPrompt.isEmpty ? nil : systemPrompt,
|
||||
generateParameters: GenerateParameters(temperature: 0.7),
|
||||
additionalContext: thinkingContext
|
||||
)
|
||||
let generateParameters = GenerateParameters(temperature: Float(documentTemperature))
|
||||
let history = conversation.messages.compactMap(historyMessage(from:))
|
||||
if history.isEmpty {
|
||||
chatSession = ChatSession(
|
||||
container,
|
||||
instructions: systemPrompt.isEmpty ? nil : systemPrompt,
|
||||
generateParameters: generateParameters,
|
||||
additionalContext: thinkingContext
|
||||
)
|
||||
} else {
|
||||
chatSession = ChatSession(
|
||||
container,
|
||||
instructions: systemPrompt.isEmpty ? nil : systemPrompt,
|
||||
history: history,
|
||||
generateParameters: generateParameters,
|
||||
additionalContext: thinkingContext
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var effectiveSystemPrompt: String {
|
||||
if let documentSystemPromptOverride {
|
||||
return documentSystemPromptOverride
|
||||
}
|
||||
|
||||
let parts = [
|
||||
Preferences.systemPrompt,
|
||||
activeScene?.systemPrompt ?? ""
|
||||
@@ -61,6 +95,10 @@ final class ChatViewModel {
|
||||
return parts.joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
private var effectiveThinkingEnabled: Bool {
|
||||
documentThinkingOverride ?? Preferences.enableThinking
|
||||
}
|
||||
|
||||
func send() {
|
||||
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, modelManager.isReady else { return }
|
||||
@@ -74,6 +112,7 @@ final class ChatViewModel {
|
||||
attachedImages = []
|
||||
|
||||
conversation.addUserMessage(text, images: images)
|
||||
markDirtyIfNeeded()
|
||||
let assistantIndex = conversation.addAssistantMessage()
|
||||
|
||||
isGenerating = true
|
||||
@@ -133,6 +172,7 @@ final class ChatViewModel {
|
||||
}
|
||||
|
||||
conversation.finalizeMessage(at: assistantIndex)
|
||||
markDirtyIfNeeded()
|
||||
isGenerating = false
|
||||
generationTask = nil
|
||||
modelManager.touchActivity()
|
||||
@@ -147,6 +187,7 @@ final class ChatViewModel {
|
||||
if let last = conversation.messages.indices.last,
|
||||
conversation.messages[last].isStreaming {
|
||||
conversation.finalizeMessage(at: last)
|
||||
markDirtyIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,8 +205,10 @@ final class ChatViewModel {
|
||||
stop()
|
||||
conversation.clear()
|
||||
inputText = ""
|
||||
attachedImages = []
|
||||
activeScene = nil
|
||||
resetSession()
|
||||
resetDocumentState()
|
||||
Preferences.lastSceneId = nil
|
||||
}
|
||||
|
||||
@@ -182,8 +225,11 @@ final class ChatViewModel {
|
||||
inputText = scene?.starterPrompt.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
attachedImages = []
|
||||
resetSession()
|
||||
resetDocumentState()
|
||||
Preferences.lastSceneId = scene?.id
|
||||
|
||||
markDirtyIfNeeded()
|
||||
|
||||
if !inputText.isEmpty {
|
||||
send()
|
||||
}
|
||||
@@ -201,6 +247,210 @@ final class ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func loadDocument(from url: URL) async throws {
|
||||
let package = try ChatDocumentPackage(contentsOf: url)
|
||||
let restoredMessages = try package.manifest.messages.map { storedMessage in
|
||||
try restoreMessage(storedMessage, attachmentContents: package.attachmentContents)
|
||||
}
|
||||
|
||||
stop()
|
||||
conversation.replaceMessages(restoredMessages)
|
||||
inputText = package.manifest.uiState.draftInput
|
||||
attachedImages = []
|
||||
activeScene = nil
|
||||
currentDocumentURL = url
|
||||
documentId = package.manifest.documentId
|
||||
documentCreatedAt = package.manifest.createdAt
|
||||
documentSystemPromptOverride = package.manifest.settings.systemPrompt
|
||||
documentThinkingOverride = package.manifest.settings.thinkingEnabled
|
||||
documentTemperature = package.manifest.settings.temperature
|
||||
resetSession()
|
||||
lastSavedSnapshotHash = try snapshotHash()
|
||||
hasUnsavedChanges = false
|
||||
Preferences.lastSceneId = nil
|
||||
|
||||
if let storedModel = package.manifest.model,
|
||||
let config = ModelConfig.resolve(storedModel.id) ?? ModelConfig.resolve(storedModel.repoId),
|
||||
modelManager.currentModel?.id != config.id {
|
||||
await modelManager.loadModel(config)
|
||||
}
|
||||
}
|
||||
|
||||
func saveDocument(to url: URL) throws {
|
||||
guard !isGenerating else {
|
||||
throw ChatDocumentError.saveWhileGenerating
|
||||
}
|
||||
|
||||
let package = try makeDocumentPackage(updatedAt: Date())
|
||||
try package.write(to: url)
|
||||
currentDocumentURL = url
|
||||
lastSavedSnapshotHash = try snapshotHash()
|
||||
hasUnsavedChanges = false
|
||||
}
|
||||
|
||||
func markDirtyIfNeeded() {
|
||||
if let lastSavedSnapshotHash {
|
||||
hasUnsavedChanges = (try? snapshotHash()) != lastSavedSnapshotHash
|
||||
} else {
|
||||
hasUnsavedChanges = !conversation.messages.isEmpty
|
||||
|| !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| activeScene != nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resetDocumentState() {
|
||||
currentDocumentURL = nil
|
||||
hasUnsavedChanges = false
|
||||
lastSavedSnapshotHash = nil
|
||||
documentId = UUID()
|
||||
documentCreatedAt = Date()
|
||||
documentSystemPromptOverride = nil
|
||||
documentThinkingOverride = nil
|
||||
documentTemperature = 0.7
|
||||
}
|
||||
|
||||
private func restoreMessage(
|
||||
_ storedMessage: ChatDocumentManifest.StoredChatMessage,
|
||||
attachmentContents: [String: Data]
|
||||
) throws -> ChatMessage {
|
||||
let attachments = try storedMessage.attachments.map { attachment in
|
||||
guard let data = attachmentContents[attachment.relativePath] else {
|
||||
throw ChatDocumentError.missingAttachment(attachment.relativePath)
|
||||
}
|
||||
guard let restoredAttachment = ChatAttachment(
|
||||
id: attachment.id,
|
||||
data: data,
|
||||
mimeType: attachment.mimeType,
|
||||
pixelWidth: attachment.pixelWidth,
|
||||
pixelHeight: attachment.pixelHeight,
|
||||
sha256: attachment.sha256
|
||||
) else {
|
||||
throw ChatDocumentError.invalidAttachmentData(attachment.relativePath)
|
||||
}
|
||||
return restoredAttachment
|
||||
}
|
||||
|
||||
return ChatMessage(
|
||||
id: storedMessage.id,
|
||||
role: ChatMessage.Role(rawValue: storedMessage.role.rawValue) ?? .assistant,
|
||||
content: storedMessage.content,
|
||||
attachments: attachments,
|
||||
isStreaming: storedMessage.streamingState == .streaming,
|
||||
timestamp: storedMessage.createdAt,
|
||||
rawContent: storedMessage.rawContent,
|
||||
thinkingContent: storedMessage.thinkingContent,
|
||||
isThinking: storedMessage.streamingState == .streaming
|
||||
)
|
||||
}
|
||||
|
||||
private func makeDocumentPackage(updatedAt: Date) throws -> ChatDocumentPackage {
|
||||
let manifest = makeManifest(updatedAt: updatedAt)
|
||||
var attachmentContents: [String: Data] = [:]
|
||||
|
||||
for message in conversation.messages {
|
||||
for attachment in message.attachments {
|
||||
attachmentContents[attachmentRelativePath(for: attachment)] = attachment.data
|
||||
}
|
||||
}
|
||||
|
||||
return ChatDocumentPackage(manifest: manifest, attachmentContents: attachmentContents)
|
||||
}
|
||||
|
||||
private func makeManifest(updatedAt: Date) -> ChatDocumentManifest {
|
||||
let messages = conversation.messages.map { message in
|
||||
ChatDocumentManifest.StoredChatMessage(
|
||||
id: message.id,
|
||||
role: ChatDocumentManifest.StoredChatMessage.Role(rawValue: message.role.rawValue) ?? .assistant,
|
||||
createdAt: message.timestamp,
|
||||
content: message.content,
|
||||
rawContent: message.rawContent,
|
||||
thinkingContent: message.thinkingContent,
|
||||
streamingState: message.isStreaming ? .streaming : .completed,
|
||||
attachments: message.attachments.map { attachment in
|
||||
ChatDocumentManifest.StoredAttachment(
|
||||
id: attachment.id,
|
||||
type: "image",
|
||||
relativePath: attachmentRelativePath(for: attachment),
|
||||
mimeType: attachment.mimeType,
|
||||
pixelWidth: attachment.pixelWidth,
|
||||
pixelHeight: attachment.pixelHeight,
|
||||
sha256: attachment.sha256
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return ChatDocumentManifest(
|
||||
schemaVersion: ChatDocumentManifest.currentSchemaVersion,
|
||||
documentId: documentId,
|
||||
createdAt: documentCreatedAt,
|
||||
updatedAt: updatedAt,
|
||||
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0",
|
||||
model: currentStoredModelInfo,
|
||||
settings: .init(
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
thinkingEnabled: effectiveThinkingEnabled,
|
||||
temperature: documentTemperature
|
||||
),
|
||||
messages: messages,
|
||||
uiState: .init(
|
||||
draftInput: inputText,
|
||||
scrollAnchorMessageId: conversation.messages.last?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var currentStoredModelInfo: ChatDocumentManifest.StoredModelInfo? {
|
||||
guard let model = modelManager.currentModel else { return nil }
|
||||
return .init(id: model.id, displayName: model.displayName, repoId: model.repoId)
|
||||
}
|
||||
|
||||
private func attachmentRelativePath(for attachment: ChatAttachment) -> String {
|
||||
"attachments/\(attachment.id.uuidString).\(attachment.fileExtension)"
|
||||
}
|
||||
|
||||
private func historyMessage(from message: ChatMessage) -> Chat.Message? {
|
||||
let role: Chat.Message.Role
|
||||
switch message.role {
|
||||
case .assistant:
|
||||
role = .assistant
|
||||
case .system:
|
||||
return nil
|
||||
case .user:
|
||||
role = .user
|
||||
}
|
||||
|
||||
return Chat.Message(
|
||||
role: role,
|
||||
content: message.sessionContent,
|
||||
images: message.attachments.compactMap(\.userInputImage)
|
||||
)
|
||||
}
|
||||
|
||||
private func snapshotHash() throws -> String {
|
||||
let snapshot = ChatDocumentSnapshot(
|
||||
documentId: documentId,
|
||||
createdAt: documentCreatedAt,
|
||||
model: currentStoredModelInfo,
|
||||
settings: .init(
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
thinkingEnabled: effectiveThinkingEnabled,
|
||||
temperature: documentTemperature
|
||||
),
|
||||
messages: makeManifest(updatedAt: documentCreatedAt).messages,
|
||||
uiState: .init(draftInput: inputText, scrollAnchorMessageId: conversation.messages.last?.id)
|
||||
)
|
||||
let data = try Self.snapshotEncoder.encode(snapshot)
|
||||
return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static var snapshotEncoder: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return encoder
|
||||
}
|
||||
|
||||
// MARK: - API Server
|
||||
|
||||
func startAPIServer() {
|
||||
|
||||
@@ -65,14 +65,16 @@ struct MessageBubbleView: View {
|
||||
|
||||
VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) {
|
||||
// Show attached images
|
||||
if !message.images.isEmpty {
|
||||
if !message.attachments.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(message.images.enumerated()), id: \.offset) { _, image in
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
ForEach(message.attachments) { attachment in
|
||||
if let image = attachment.image {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
README.md
12
README.md
@@ -32,9 +32,10 @@ open "build/Debug/MLX Server.app"
|
||||
- **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.
|
||||
- **Streaming responses** with live token display
|
||||
- **Export chat** — File > Export Chat (Cmd+Shift+S) saves conversations as Markdown or RTF (Pages-compatible)
|
||||
- **Native chat documents** — save chats as `.mlxchat` package documents, reopen them from File > Open Chat or by double-clicking them in Finder, and continue the conversation with restored model context, thinking blocks, and images
|
||||
- **Export chat** — File > Export Chat (Cmd+Shift+E) 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)
|
||||
- **Keyboard shortcuts**: `Cmd+N` (new chat), `Cmd+O` (open chat document), `Cmd+S` (save chat document), `Cmd+Shift+S` (save chat document as), `Cmd+Shift+E` (export), `Cmd+Return` (send), `Escape` (stop), `Cmd+1/2/3/4` (switch models)
|
||||
- **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
|
||||
@@ -109,7 +110,12 @@ MLXServer/
|
||||
│ ├── MonitorView.swift — Inference statistics monitor
|
||||
│ └── SettingsView.swift — System prompt, thinking mode, API, idle settings
|
||||
├── Commands/
|
||||
│ └── SaveChatCommands.swift — File menu export command
|
||||
│ └── SaveChatCommands.swift — File menu new/open/save/revert/export commands
|
||||
├── Documents/
|
||||
│ ├── ChatDocumentController.swift — Queues Finder/app open-document requests into SwiftUI
|
||||
│ ├── ChatDocumentManifest.swift — Versioned `.mlxchat` manifest schema
|
||||
│ ├── ChatDocumentMigration.swift — Manifest schema migration entry point
|
||||
│ └── ChatDocumentPackage.swift — Package document read/write for `.mlxchat`
|
||||
├── Server/
|
||||
│ ├── APIServer.swift — NWListener HTTP server, SSE streaming, KV cache reuse
|
||||
│ ├── APIModels.swift — OpenAI-compatible Codable structs
|
||||
|
||||
@@ -28,9 +28,8 @@ targets:
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "15.0"
|
||||
GENERATE_INFOPLIST_FILE: "YES"
|
||||
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools"
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: ""
|
||||
GENERATE_INFOPLIST_FILE: "NO"
|
||||
INFOPLIST_FILE: MLXServer/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: MLXServer/MLXServer.entitlements
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: "YES"
|
||||
|
||||
Reference in New Issue
Block a user