mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 21:33:54 +00:00
* feat(ios): read-only offline cache for chat sessions and transcripts Cache-first cold open for the iOS chat tab: the last known transcript and session list render immediately from a local SQLite cache, then live gateway history replaces them wholesale through the existing reconciliation path. When the gateway is unreachable, recent sessions and transcripts stay browsable read-only; sending remains gated by the existing connection state. - New OpenClawChatTranscriptCache protocol seam plus SQLite-backed store in OpenClawChatUI (raw SQLite3, no new dependencies), scoped per gateway stableID so transcripts never leak across paired gateways. - Bounds: 50 sessions, 50 transcripts, 200 messages per session; text rows only (attachment/binary payloads and tool arguments are stripped). - Disposable cache: schema versioned via user_version; any open/schema/decode mismatch drops and rebuilds silently. File protection completeUntilFirstUserAuthentication on iOS. - View model pre-paints from cache only until a live response applies (hasAppliedLiveHistory/hasAppliedLiveSessions guards); write-through is chained and off the render path. - iOS wiring keys the chat view model identity on transport mode plus cache gateway ID so switching paired gateways rebuilds the view model. Part of #100194 * fix(ios): harden offline transcript cache * fix(ios): expose offline cached sessions * fix(ios): isolate transcript caches by gateway * chore(i18n): sync native inventory * fix(ios): allow cache extension to replace messages
381 lines
15 KiB
Swift
381 lines
15 KiB
Swift
import Foundation
|
|
import OSLog
|
|
import SQLite3
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
private let cacheLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatTranscriptCache")
|
|
|
|
/// Read-only offline cache seam for chat sessions and transcripts.
|
|
///
|
|
/// The cache only pre-paints cold opens and covers offline browsing; connected
|
|
/// reads always come from the gateway and replace cached content wholesale.
|
|
/// Implementations must scope all rows to a single gateway identity so
|
|
/// transcripts never leak across paired gateways.
|
|
public protocol OpenClawChatTranscriptCache: Sendable {
|
|
func loadSessions() async -> [OpenClawChatSessionEntry]
|
|
func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage]
|
|
func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async
|
|
func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async
|
|
}
|
|
|
|
/// SQLite-backed transcript cache for one gateway identity. Owners should use
|
|
/// one database file per gateway so reset can physically remove that gateway's
|
|
/// cached transcript bytes without disturbing other paired gateways.
|
|
///
|
|
/// The cache is disposable: any open, schema, or decode mismatch drops the
|
|
/// affected state and rebuilds silently. There are no migrations.
|
|
public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache {
|
|
/// Bounds keep the cache small: enough for a recently-used session picker
|
|
/// and a full first screen of transcript, not a durable archive.
|
|
public static let maxCachedSessions = 50
|
|
public static let maxCachedTranscripts = 50
|
|
public static let maxCachedMessagesPerSession = 200
|
|
static let schemaVersion: Int32 = 1
|
|
|
|
/// Owns the raw sqlite handle so it closes on release without needing an
|
|
/// isolated actor deinit (OpaquePointer is not Sendable).
|
|
private final class Connection: @unchecked Sendable {
|
|
let raw: OpaquePointer
|
|
|
|
init(raw: OpaquePointer) {
|
|
self.raw = raw
|
|
}
|
|
|
|
deinit {
|
|
sqlite3_close_v2(self.raw)
|
|
}
|
|
}
|
|
|
|
private let databaseURL: URL
|
|
private let gatewayID: String
|
|
private var db: Connection?
|
|
private var isRetired = false
|
|
/// After a failed drop-and-rebuild the cache becomes a no-op instead of
|
|
/// erroring the chat surface; a fresh launch retries from scratch.
|
|
private var isBroken = false
|
|
|
|
public init(databaseURL: URL, gatewayID: String) {
|
|
self.databaseURL = databaseURL
|
|
self.gatewayID = gatewayID
|
|
}
|
|
|
|
/// Startup-only cleanup, before any cache actor can own an open handle.
|
|
public static func removeDatabaseFiles(at databaseURL: URL) {
|
|
let fm = FileManager.default
|
|
try? fm.removeItem(at: databaseURL)
|
|
for suffix in ["-wal", "-shm", "-journal"] {
|
|
try? fm.removeItem(at: URL(fileURLWithPath: databaseURL.path + suffix))
|
|
}
|
|
}
|
|
|
|
// MARK: - OpenClawChatTranscriptCache
|
|
|
|
public func loadSessions() async -> [OpenClawChatSessionEntry] {
|
|
guard !self.isRetired else { return [] }
|
|
guard let db = await self.handle() else { return [] }
|
|
guard let payload = self.selectPayload(
|
|
db,
|
|
sql: "SELECT payload FROM cached_sessions WHERE gateway_id = ?1",
|
|
bindings: [self.gatewayID])
|
|
else {
|
|
return []
|
|
}
|
|
guard let decoded = try? JSONDecoder().decode(
|
|
[OpenClawChatSessionEntry].self,
|
|
from: Data(payload.utf8))
|
|
else {
|
|
// Decode mismatch means a stale/foreign shape: drop the row silently.
|
|
self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID])
|
|
return []
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
public func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] {
|
|
guard !self.isRetired else { return [] }
|
|
guard let db = await self.handle() else { return [] }
|
|
guard let payload = self.selectPayload(
|
|
db,
|
|
sql: "SELECT payload FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
|
|
bindings: [self.gatewayID, sessionKey])
|
|
else {
|
|
return []
|
|
}
|
|
guard let decoded = try? JSONDecoder().decode(
|
|
[OpenClawChatMessage].self,
|
|
from: Data(payload.utf8))
|
|
else {
|
|
self.execute(
|
|
db,
|
|
sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
|
|
bindings: [self.gatewayID, sessionKey])
|
|
return []
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
public func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async {
|
|
guard !self.isRetired else { return }
|
|
guard let db = await self.handle() else { return }
|
|
let bounded = Self.boundedSessions(sessions)
|
|
guard !bounded.isEmpty else {
|
|
self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID])
|
|
return
|
|
}
|
|
guard let payload = Self.encodeJSON(bounded) else { return }
|
|
self.execute(
|
|
db,
|
|
sql: """
|
|
INSERT OR REPLACE INTO cached_sessions(gateway_id, payload, updated_at)
|
|
VALUES (?1, ?2, ?3)
|
|
""",
|
|
bindings: [self.gatewayID, payload, Date().timeIntervalSince1970])
|
|
}
|
|
|
|
public func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async {
|
|
guard !self.isRetired else { return }
|
|
guard let db = await self.handle() else { return }
|
|
let bounded = Self.cacheableMessages(messages)
|
|
guard !bounded.isEmpty else {
|
|
// An emptied live transcript must also empty the cache, or the next
|
|
// cold open would ghost-paint messages the gateway no longer has.
|
|
self.execute(
|
|
db,
|
|
sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
|
|
bindings: [self.gatewayID, sessionKey])
|
|
return
|
|
}
|
|
guard let payload = Self.encodeJSON(bounded) else { return }
|
|
self.execute(
|
|
db,
|
|
sql: """
|
|
INSERT OR REPLACE INTO cached_transcripts(gateway_id, session_key, payload, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4)
|
|
""",
|
|
bindings: [self.gatewayID, sessionKey, payload, Date().timeIntervalSince1970])
|
|
// rowid tie-breaks equal timestamps: INSERT OR REPLACE mints a fresh
|
|
// rowid, so the most recently written transcript always survives.
|
|
self.execute(
|
|
db,
|
|
sql: """
|
|
DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key NOT IN (
|
|
SELECT session_key FROM cached_transcripts WHERE gateway_id = ?1
|
|
ORDER BY updated_at DESC, rowid DESC LIMIT \(Self.maxCachedTranscripts)
|
|
)
|
|
""",
|
|
bindings: [self.gatewayID])
|
|
}
|
|
|
|
public func retire() async {
|
|
// A queued write then either finishes before retirement or becomes a
|
|
// no-op. Closing the handle lets the owner delete the whole cache file.
|
|
self.isRetired = true
|
|
self.db = nil
|
|
}
|
|
|
|
// MARK: - Cached shapes
|
|
|
|
/// Text rows only in v1: strip attachment/binary payloads and tool
|
|
/// arguments so the cache never persists base64 blobs or large payloads.
|
|
static func cacheableMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
|
messages.suffix(self.maxCachedMessagesPerSession).map { message in
|
|
OpenClawChatMessage(
|
|
id: message.id,
|
|
role: message.role,
|
|
content: message.content.map { item in
|
|
OpenClawChatMessageContent(
|
|
type: item.type,
|
|
text: item.text,
|
|
thinking: item.thinking,
|
|
thinkingSignature: nil,
|
|
mimeType: item.mimeType,
|
|
fileName: item.fileName,
|
|
content: nil,
|
|
id: item.id,
|
|
name: item.name,
|
|
arguments: nil)
|
|
},
|
|
timestamp: message.timestamp,
|
|
idempotencyKey: message.idempotencyKey,
|
|
toolCallId: message.toolCallId,
|
|
toolName: message.toolName,
|
|
usage: message.usage,
|
|
stopReason: message.stopReason,
|
|
errorMessage: message.errorMessage)
|
|
}
|
|
}
|
|
|
|
static func boundedSessions(_ sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry] {
|
|
guard sessions.count > self.maxCachedSessions else { return sessions }
|
|
return Array(
|
|
sessions
|
|
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
|
.prefix(self.maxCachedSessions))
|
|
}
|
|
|
|
private static func encodeJSON(_ value: some Encodable) -> String? {
|
|
guard let data = try? JSONEncoder().encode(value) else { return nil }
|
|
return String(bytes: data, encoding: .utf8)
|
|
}
|
|
|
|
// MARK: - Connection lifecycle
|
|
|
|
private func handle() async -> OpaquePointer? {
|
|
guard !self.isRetired else { return nil }
|
|
if let db { return db.raw }
|
|
if self.isBroken { return nil }
|
|
#if os(iOS)
|
|
// Complete protection intentionally makes the cache unavailable while
|
|
// locked. Treat that as a temporary miss, never as corruption.
|
|
guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil }
|
|
#endif
|
|
if let opened = self.openConnection() {
|
|
self.db = Connection(raw: opened)
|
|
return opened
|
|
}
|
|
#if os(iOS)
|
|
guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil }
|
|
#endif
|
|
// Cache is disposable: on any open/schema failure drop the file
|
|
// (and SQLite sidecars) and rebuild once, silently.
|
|
self.removeDatabaseFiles()
|
|
if let reopened = self.openConnection() {
|
|
self.db = Connection(raw: reopened)
|
|
return reopened
|
|
}
|
|
cacheLogger.error("chat transcript cache unavailable; continuing without offline cache")
|
|
self.isBroken = true
|
|
return nil
|
|
}
|
|
|
|
#if os(iOS)
|
|
private func isProtectedDataAvailable() async -> Bool {
|
|
await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
|
|
}
|
|
#endif
|
|
|
|
private func openConnection() -> OpaquePointer? {
|
|
let fm = FileManager.default
|
|
try? fm.createDirectory(
|
|
at: self.databaseURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
var opened: OpaquePointer?
|
|
var flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
|
|
#if os(iOS)
|
|
// Apply Complete protection through the SQLite VFS so auxiliary files
|
|
// receive the same class as the main transcript database.
|
|
flags |= SQLITE_OPEN_FILEPROTECTION_COMPLETE
|
|
#endif
|
|
guard sqlite3_open_v2(self.databaseURL.path, &opened, flags, nil) == SQLITE_OK, let opened else {
|
|
sqlite3_close_v2(opened)
|
|
return nil
|
|
}
|
|
guard let version = self.readUserVersion(opened) else {
|
|
sqlite3_close_v2(opened)
|
|
return nil
|
|
}
|
|
if version == 0 {
|
|
guard self.createSchema(opened) else {
|
|
sqlite3_close_v2(opened)
|
|
return nil
|
|
}
|
|
} else if version != Self.schemaVersion {
|
|
// Unknown schema: no migrations by design, force drop-and-rebuild.
|
|
sqlite3_close_v2(opened)
|
|
return nil
|
|
}
|
|
#if os(iOS)
|
|
// Upgrade a database created by an older build to the stricter class.
|
|
try? fm.setAttributes(
|
|
[.protectionKey: FileProtectionType.complete],
|
|
ofItemAtPath: self.databaseURL.path)
|
|
#endif
|
|
return opened
|
|
}
|
|
|
|
private func readUserVersion(_ db: OpaquePointer) -> Int32? {
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK else {
|
|
return nil
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
|
|
return sqlite3_column_int(statement, 0)
|
|
}
|
|
|
|
private func createSchema(_ db: OpaquePointer) -> Bool {
|
|
let statements = [
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS cached_sessions(
|
|
gateway_id TEXT NOT NULL PRIMARY KEY,
|
|
payload TEXT NOT NULL,
|
|
updated_at REAL NOT NULL
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS cached_transcripts(
|
|
gateway_id TEXT NOT NULL,
|
|
session_key TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY(gateway_id, session_key)
|
|
)
|
|
""",
|
|
"PRAGMA user_version = \(Self.schemaVersion)",
|
|
]
|
|
for sql in statements {
|
|
guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return false }
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Statement helpers
|
|
|
|
@discardableResult
|
|
private func execute(_ db: OpaquePointer, sql: String, bindings: [Any]) -> Bool {
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
cacheLogger.error("cache statement prepare failed")
|
|
return false
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
guard self.bind(statement, bindings: bindings) else { return false }
|
|
return sqlite3_step(statement) == SQLITE_DONE
|
|
}
|
|
|
|
private func selectPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> String? {
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil }
|
|
defer { sqlite3_finalize(statement) }
|
|
guard self.bind(statement, bindings: bindings) else { return nil }
|
|
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
|
|
guard let text = sqlite3_column_text(statement, 0) else { return nil }
|
|
return String(cString: text)
|
|
}
|
|
|
|
private func bind(_ statement: OpaquePointer?, bindings: [Any]) -> Bool {
|
|
// SQLITE_TRANSIENT: sqlite copies the buffer before the Swift string dies.
|
|
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
for (offset, value) in bindings.enumerated() {
|
|
let index = Int32(offset + 1)
|
|
let result: Int32 = switch value {
|
|
case let text as String:
|
|
sqlite3_bind_text(statement, index, text, -1, transient)
|
|
case let real as Double:
|
|
sqlite3_bind_double(statement, index, real)
|
|
default:
|
|
SQLITE_MISUSE
|
|
}
|
|
guard result == SQLITE_OK else { return false }
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func removeDatabaseFiles() {
|
|
self.db = nil
|
|
Self.removeDatabaseFiles(at: self.databaseURL)
|
|
}
|
|
}
|