Skip to content

Commit 2bc51c6

Browse files
committed
feat(api): add web UI support endpoints for shared topics and mini-prompts
**New API endpoints:** - GET/POST/PATCH/DELETE /api/shared-topics - CRUD for shared topics - POST /v1/conversations/:id/attach-topic - Attach conversation to shared topic - POST /v1/conversations/:id/detach-topic - Detach conversation from shared topic - POST/PATCH/DELETE /api/mini-prompts - CRUD for mini-prompt management **Backend changes:** - SAMAPIServer: Added handlers for shared topics and mini-prompts - CustomCORSMiddleware: New CORS middleware for web UI support - EndpointManager, MLXProvider, ModelListManager: API improvements - Removed Training subsystem (deprecated) **Purpose:** These changes enable the sam-web interface to manage shared topics and mini-prompts via REST API. All web UI code (sam-web/) will move to separate repository. **Testing:** ✅ Build: PASS ✅ API endpoints functional ✅ Web UI integration verified
1 parent 08baf4d commit 2bc51c6

27 files changed

Lines changed: 938 additions & 5379 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// SPDX-FileCopyrightText: Copyright (c) 2026 Andrew Wyatt (Fewtarius)
3+
4+
import Vapor
5+
6+
/// Custom CORS middleware that manually injects headers into every response
7+
/// This is a workaround for Vapor's CORSMiddleware not working as expected
8+
struct CustomCORSMiddleware: AsyncMiddleware {
9+
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
10+
// Get origin from request
11+
let origin = request.headers.first(name: "Origin") ?? "*"
12+
13+
// Handle preflight OPTIONS requests
14+
if request.method == .OPTIONS {
15+
let response = Response(status: .ok)
16+
response.headers.replaceOrAdd(name: .accessControlAllowOrigin, value: origin)
17+
response.headers.replaceOrAdd(name: .accessControlAllowMethods, value: "GET, POST, PUT, DELETE, PATCH, OPTIONS")
18+
response.headers.replaceOrAdd(name: .accessControlAllowHeaders, value: "Accept, Authorization, Content-Type, Origin, X-Requested-With")
19+
response.headers.replaceOrAdd(name: .accessControlMaxAge, value: "3600")
20+
return response
21+
}
22+
23+
// Process request normally
24+
let response = try await next.respond(to: request)
25+
26+
// Add CORS headers to response (replaceOrAdd ensures no duplicates)
27+
response.headers.replaceOrAdd(name: .accessControlAllowOrigin, value: origin)
28+
response.headers.replaceOrAdd(name: .accessControlAllowMethods, value: "GET, POST, PUT, DELETE, PATCH, OPTIONS")
29+
response.headers.replaceOrAdd(name: .accessControlAllowHeaders, value: "Accept, Authorization, Content-Type, Origin, X-Requested-With")
30+
31+
return response
32+
}
33+
}

Sources/APIFramework/EndpointManager.swift

Lines changed: 0 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import ConversationEngine
66
import ConfigurationSystem
77
import MCPFramework
88
import Logging
9-
import Training
109
extension Notification.Name {
1110
/// Posted when providers/endpoints are reloaded and available models may have changed.
1211
public static let endpointManagerDidReloadProviders = Notification.Name("com.sam.endpointmanager.providersReloaded")
@@ -90,46 +89,6 @@ public class EndpointManager: ObservableObject {
9089
self.reloadProviderConfigurations()
9190
}
9291
}
93-
94-
/// Listen for LoRA adapter changes
95-
NotificationCenter.default.addObserver(
96-
forName: .loraAdaptersDidChange,
97-
object: nil,
98-
queue: .main
99-
) { [weak self] notification in
100-
// Extract values outside Task to avoid concurrency issues
101-
var adapterId: String?
102-
var adapterName: String?
103-
var baseModelId: String?
104-
105-
if let adapter = notification.object as? LoRAAdapter {
106-
adapterId = adapter.id
107-
adapterName = adapter.metadata.adapterName
108-
baseModelId = adapter.baseModelId
109-
}
110-
111-
Task { @MainActor in
112-
guard let self = self, let localModelManager = self.localModelManager else { return }
113-
114-
/// Register adapter with LocalModelManager
115-
/// CRITICAL: Use adapter ID as modelName to match ModelListManager format (lora/{uuid})
116-
if let aid = adapterId, let aname = adapterName {
117-
let adapterDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
118-
.appendingPathComponent("SAM/adapters/\(aid)")
119-
localModelManager.registerModel(
120-
provider: "lora",
121-
modelName: aid, // Use UUID, not adapter name
122-
path: adapterDir.path,
123-
sizeBytes: nil,
124-
quantization: "lora"
125-
)
126-
self.logger.info("Registered LoRA adapter: \(aname) (ID: \(aid))")
127-
}
128-
129-
/// Trigger provider reload to create/remove providers
130-
self.reloadProviderConfigurations()
131-
}
132-
}
13392
}
13493

13594
// MARK: - Public Interface
@@ -343,11 +302,6 @@ public class EndpointManager: ObservableObject {
343302

344303
/// Check if a model is a local model (MLX or GGUF) Returns true for local models, false for API-based models.
345304
public func isLocalModel(_ modelId: String) -> Bool {
346-
/// LoRA adapters are always local
347-
if modelId.hasPrefix("lora/") {
348-
return true
349-
}
350-
351305
guard let providerType = getProviderTypeForModel(modelId) else {
352306
return false
353307
}
@@ -1205,151 +1159,6 @@ public class EndpointManager: ObservableObject {
12051159
providers[providerIdentifier] = provider
12061160
logger.debug("Hot reload: Created MLX model provider: \(providerIdentifier)")
12071161
}
1208-
1209-
/// Also handle LoRA adapters (quantization == "lora")
1210-
let loraAdapters = registryModels.filter { $0.quantization == "lora" }
1211-
logger.debug("Hot reload: Found \(loraAdapters.count) LoRA adapters in registry")
1212-
1213-
/// Also scan adapters directory for any unregistered adapters
1214-
let adaptersDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
1215-
.appendingPathComponent("SAM/adapters")
1216-
if FileManager.default.fileExists(atPath: adaptersDir.path) {
1217-
let adapterDirs = (try? FileManager.default.contentsOfDirectory(at: adaptersDir, includingPropertiesForKeys: nil)) ?? []
1218-
for adapterDir in adapterDirs where adapterDir.hasDirectoryPath {
1219-
let adapterId = adapterDir.lastPathComponent
1220-
/// Check if already registered
1221-
if loraAdapters.contains(where: { $0.path == adapterDir.path }) {
1222-
continue // Already in registry
1223-
}
1224-
1225-
/// Load metadata to get adapter name and base model
1226-
let metadataPath = adapterDir.appendingPathComponent("metadata.json")
1227-
guard let metadataData = try? Data(contentsOf: metadataPath),
1228-
let metadataDict = try? JSONSerialization.jsonObject(with: metadataData) as? [String: Any],
1229-
let adapterName = metadataDict["adapterName"] as? String,
1230-
let baseModelId = metadataDict["baseModelId"] as? String else {
1231-
logger.warning("Hot reload: Failed to load metadata for unregistered adapter: \(adapterId)")
1232-
continue
1233-
}
1234-
1235-
/// Register with LocalModelManager
1236-
/// CRITICAL: Use adapter ID (UUID) as modelName to match ModelListManager format
1237-
modelManager.registerModel(
1238-
provider: "lora",
1239-
modelName: adapterId, // Use UUID, not adapter name
1240-
path: adapterDir.path,
1241-
sizeBytes: nil,
1242-
quantization: "lora"
1243-
)
1244-
logger.info("Hot reload: Registered previously unregistered LoRA adapter: \(adapterName) (ID: \(adapterId))")
1245-
}
1246-
}
1247-
1248-
/// Re-query adapters after potential registration
1249-
let allLoraAdapters = modelManager.getAllRegistryModels().filter { $0.quantization == "lora" }
1250-
logger.debug("Hot reload: Total LoRA adapters after scan: \(allLoraAdapters.count)")
1251-
1252-
for adapter in allLoraAdapters {
1253-
let providerIdentifier = adapter.identifier
1254-
1255-
/// Skip if provider already exists (prevents duplicates during hot reload)
1256-
if providers[providerIdentifier] != nil {
1257-
logger.debug("Hot reload: Skipping LoRA adapter \(providerIdentifier) (provider already exists)")
1258-
continue
1259-
}
1260-
1261-
/// Load adapter metadata to get base model ID
1262-
let adapterPath = URL(fileURLWithPath: adapter.path)
1263-
let metadataPath = adapterPath.appendingPathComponent("metadata.json")
1264-
guard let metadataData = try? Data(contentsOf: metadataPath),
1265-
let metadataDict = try? JSONSerialization.jsonObject(with: metadataData) as? [String: Any],
1266-
let baseModelId = metadataDict["baseModelId"] as? String else {
1267-
logger.warning("Failed to load metadata for LoRA adapter: \(adapter.modelName)")
1268-
continue
1269-
}
1270-
1271-
/// Get base model path - look for it in the providers we just created
1272-
/// LoRA adapters are created AFTER MLX models, so base model provider should already exist
1273-
var baseModelPath: String?
1274-
1275-
/// Check all registry entries for matching model
1276-
for entry in modelManager.getAllRegistryModels() {
1277-
/// Match by identifier or by model name
1278-
if entry.identifier == baseModelId || entry.modelName == baseModelId {
1279-
baseModelPath = entry.path
1280-
break
1281-
}
1282-
}
1283-
1284-
guard let baseModel = baseModelPath else {
1285-
logger.warning("Base model not found for LoRA adapter: \(adapter.modelName) (base: \(baseModelId))")
1286-
logger.debug("Available models in registry: \(modelManager.getAllRegistryModels().map { $0.identifier }.joined(separator: ", "))")
1287-
continue
1288-
}
1289-
1290-
/// For MLX models, ensure path points to directory, not to model.safetensors file
1291-
/// The registry might store "/path/to/model/model.safetensors", but MLXProvider needs "/path/to/model"
1292-
let modelDirectory: String
1293-
if baseModel.hasSuffix("/model.safetensors") || baseModel.hasSuffix(".safetensors") {
1294-
/// Path points to file - get parent directory
1295-
modelDirectory = URL(fileURLWithPath: baseModel).deletingLastPathComponent().path
1296-
} else {
1297-
/// Path already points to directory
1298-
modelDirectory = baseModel
1299-
}
1300-
1301-
/// Validate that the model directory contains required files
1302-
/// This prevents using empty/incomplete model directories as base models
1303-
/// Support both single-file and split models
1304-
let modelDirURL = URL(fileURLWithPath: modelDirectory)
1305-
let singleFile = modelDirURL.appendingPathComponent("model.safetensors").path
1306-
let indexFile = modelDirURL.appendingPathComponent("model.safetensors.index.json").path
1307-
let splitFile = modelDirURL.appendingPathComponent("model-00001-of-00002.safetensors").path
1308-
1309-
let isValidModel = FileManager.default.fileExists(atPath: singleFile) ||
1310-
FileManager.default.fileExists(atPath: indexFile) ||
1311-
FileManager.default.fileExists(atPath: splitFile)
1312-
1313-
guard isValidModel else {
1314-
logger.warning("Base model directory is invalid/empty for LoRA adapter: \(adapter.modelName)")
1315-
logger.warning(" Base model: \(baseModelId)")
1316-
logger.warning(" Path: \(modelDirectory)")
1317-
logger.warning(" Skipping adapter (base model incomplete or not downloaded)")
1318-
continue
1319-
}
1320-
1321-
/// Get adapter ID from path (last component)
1322-
let adapterId = adapterPath.lastPathComponent
1323-
1324-
/// Create provider-specific config
1325-
let providerSpecificConfig = ProviderConfiguration(
1326-
providerId: providerIdentifier,
1327-
providerType: .localMLX,
1328-
isEnabled: config.isEnabled,
1329-
baseURL: nil,
1330-
models: [providerIdentifier],
1331-
maxTokens: config.maxTokens,
1332-
temperature: config.temperature,
1333-
customHeaders: config.customHeaders,
1334-
timeoutSeconds: config.timeoutSeconds,
1335-
retryCount: config.retryCount
1336-
)
1337-
1338-
/// Create MLX provider with LoRA adapter
1339-
let provider = MLXProvider(
1340-
config: providerSpecificConfig,
1341-
modelPath: modelDirectory,
1342-
loraAdapterId: adapterId,
1343-
onModelLoadingStarted: { @MainActor [weak self] providerId, modelName in
1344-
self?.notifyModelLoadingStarted(providerId: providerId, modelName: modelName)
1345-
},
1346-
onModelLoadingCompleted: { @MainActor [weak self] providerId in
1347-
self?.notifyModelLoadingCompleted(providerId: providerId)
1348-
}
1349-
)
1350-
providers[providerIdentifier] = provider
1351-
logger.debug("Hot reload: Created LoRA adapter provider: \(providerIdentifier) (base: \(baseModelId), path: \(modelDirectory))")
1352-
}
13531162
}
13541163
} else {
13551164
providers[providerId] = createProvider(type: providerType, config: config)

0 commit comments

Comments
 (0)