Files
2026-07-13 13:33:03 +08:00

266 lines
11 KiB
Swift

//
// ChatHistoryDatabase.swift
// MNNLLMiOS
//
// Created by 游薪渝(揽清) on 2025/1/15.
//
import ExyteChat
import SQLite
class ChatHistoryDatabase {
static let shared: ChatHistoryDatabase? = {
do {
return try ChatHistoryDatabase()
} catch {
print("Failed to initialize ChatHistoryDatabase: \(error)")
return nil
}
}()
private let db: Connection
private let chatHistories: Table
private typealias Column<T> = SQLite.Expression<T>
private let id: Column<String>
private let modelId: Column<String>
private let modelName: Column<String>
private let modelInfo: Column<String?> // JSON string of ModelInfo
private let messages: Column<String>
private let createdAt: Column<Date>
private let updatedAt: Column<Date>
private init() throws {
guard let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else {
throw NSError(domain: "ChatHistoryDatabase", code: -1, userInfo: [NSLocalizedDescriptionKey: "Documents directory not found"])
}
db = try Connection("\(path)/chatHistory.sqlite3")
chatHistories = Table("chatHistories")
id = Column<String>("id")
modelId = Column<String>("modelId")
modelName = Column<String>("modelName")
modelInfo = Column<String?>("modelInfo")
messages = Column<String>("messages")
createdAt = Column<Date>("createdAt")
updatedAt = Column<Date>("updatedAt")
try db.run(chatHistories.create(ifNotExists: true) { t in
t.column(id, primaryKey: true)
t.column(modelId)
t.column(modelName)
t.column(modelInfo)
t.column(messages)
t.column(createdAt)
t.column(updatedAt)
})
// for old data
try migrateDatabase()
}
private func migrateDatabase() throws {
do {
_ = try db.scalar("SELECT modelInfo FROM chatHistories LIMIT 1")
} catch {
try db.run("ALTER TABLE chatHistories ADD COLUMN modelInfo TEXT")
}
}
func saveChat(historyId: String, modelInfo: ModelInfo, messages: [Message]) {
let modelId = modelInfo.id
let modelName = modelInfo.modelName
do {
ChatHistoryFileManager.shared.createHistoryDirectory(for: historyId)
var historyMessages: [HistoryMessage] = []
for message in messages {
var copiedImages: [LLMChatImage] = []
for msg in message.attachments {
if msg.type == .image {
var imageUrl = msg.full
print("Processing image attachment: \(imageUrl.path)")
guard let copiedImage = ChatHistoryFileManager.shared.copyFile(from: imageUrl, for: historyId) else {
print("Failed to copy image file: \(imageUrl.path)")
continue
}
imageUrl = copiedImage
print("Image copied to: \(imageUrl.path)")
if imageUrl.isHEICImage() {
guard let jpgUrl = AssetExtractor.convertHEICToJPG(heicUrl: imageUrl) else {
print("Failed to convert HEIC to JPG: \(imageUrl.path)")
continue
}
imageUrl = jpgUrl
print("HEIC converted to JPG: \(imageUrl.path)")
}
if ChatHistoryFileManager.shared.validateFileExists(at: imageUrl) {
copiedImages.append(LLMChatImage(id: msg.id, thumbnail: imageUrl, full: imageUrl))
print("Image successfully saved for history: \(imageUrl.path)")
} else {
print("Final image file validation failed: \(imageUrl.path)")
}
}
}
var copiedRecording: Recording?
if let recording = message.recording, let recUrl = recording.url {
let recUrl = ChatHistoryFileManager.shared.copyFile(from: recUrl, for: historyId)
copiedRecording = Recording(duration: recording.duration, waveformSamples: recording.waveformSamples, url: recUrl)
}
historyMessages.append(HistoryMessage(
id: message.id,
content: message.text,
images: copiedImages,
audio: copiedRecording,
isUser: message.user.isCurrentUser,
createdAt: message.createdAt
))
}
let messagesData = try JSONEncoder().encode(historyMessages)
let messagesString = String(data: messagesData, encoding: .utf8)!
let modelInfoData = try JSONEncoder().encode(modelInfo)
let modelInfoString = String(data: modelInfoData, encoding: .utf8)!
if let _ = try? db.pluck(chatHistories.filter(id == historyId)) {
try db.run(chatHistories.filter(id == historyId).update(
self.messages <- messagesString,
self.modelInfo <- modelInfoString,
updatedAt <- Date()
))
} else {
try db.run(chatHistories.insert(
id <- historyId,
self.modelId <- modelId,
self.modelName <- modelName,
self.modelInfo <- modelInfoString,
self.messages <- messagesString,
createdAt <- Date(),
updatedAt <- Date()
))
}
} catch {
print("Failed to save chat: \(error)")
}
}
// For backward compatibility
func saveChat(historyId: String, modelId: String, modelName _: String, messages: [Message]) {
let modelInfo = createFallbackModelInfo(for: modelId)
saveChat(historyId: historyId, modelInfo: modelInfo, messages: messages)
}
/// Create fallback ModelInfo that correctly identifies local models
private func createFallbackModelInfo(for modelId: String) -> ModelInfo {
// Check if this modelId corresponds to a local model
let localModels = ModelInfo.getAvailableLocalModels()
// Try to find matching local model by name or id
if let localModel = localModels.first(where: { $0.modelName == modelId || $0.id.contains(modelId) }) {
return localModel
}
// Fallback to downloaded model
return ModelInfo(modelId: modelId, isDownloaded: true)
}
func getAllHistory() -> [ChatHistory] {
var histories: [ChatHistory] = []
do {
for history in try db.prepare(chatHistories) {
let messagesData = history[messages].data(using: .utf8)!
var historyMessages = try JSONDecoder().decode([HistoryMessage].self, from: messagesData)
historyMessages = validateAndFixImagePaths(historyMessages, historyId: history[id])
var modelInfoObj: ModelInfo
do {
if let modelInfoString = try? history.get(modelInfo), !modelInfoString.isEmpty {
do {
let modelInfoData = modelInfoString.data(using: .utf8)!
modelInfoObj = try JSONDecoder().decode(ModelInfo.self, from: modelInfoData)
// print("Successfully decoded ModelInfo from JSON for history: \(history[id])")
} catch {
// print("Failed to decode ModelInfo from JSON, using fallback: \(error)")
modelInfoObj = createFallbackModelInfo(for: history[modelId])
}
} else {
// For backward compatibility
// print("No modelInfo data found, using fallback for history: \(history[id])")
modelInfoObj = createFallbackModelInfo(for: history[modelId])
}
} catch {
// For backward compatibility
// print("ModelInfo column not found, using fallback for history: \(history[id])")
modelInfoObj = createFallbackModelInfo(for: history[modelId])
}
let chatHistory = ChatHistory(
id: history[id],
modelInfo: modelInfoObj,
messages: historyMessages,
createdAt: history[createdAt],
updatedAt: history[updatedAt]
)
histories.append(chatHistory)
}
} catch {
print("Failed to fetch histories: \(error)")
}
return histories
}
private func validateAndFixImagePaths(_ messages: [HistoryMessage], historyId: String) -> [HistoryMessage] {
messages.map { message in
var updatedMessage = message
if let images = message.images {
let validImages = images.compactMap { image -> LLMChatImage? in
if ChatHistoryFileManager.shared.validateFileExists(at: image.full) {
return image
} else {
let fileName = image.full.lastPathComponent
if let validURL = ChatHistoryFileManager.shared.getValidFileURL(for: fileName, historyId: historyId) {
return LLMChatImage(id: image.id, thumbnail: validURL, full: validURL)
} else {
print("Image file not found and cannot be recovered: \(image.full.path)")
return nil
}
}
}
updatedMessage = HistoryMessage(
id: message.id,
content: message.content,
images: validImages.isEmpty ? nil : validImages,
audio: message.audio,
isUser: message.isUser,
createdAt: message.createdAt
)
}
return updatedMessage
}
}
func deleteHistory(_ history: ChatHistory) {
do {
try db.run(chatHistories.filter(id == history.id).delete())
ChatHistoryFileManager.shared.deleteHistoryDirectory(for: history.id)
} catch {
print("Failed to delete history: \(error)")
}
}
}