mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 04:22:22 +08:00
* feat(ios): durable offline command outbox for chat sends
Text messages sent while the gateway is unreachable queue in a durable
per-gateway outbox (new outbox_commands table in the per-gateway chat
cache SQLite store, schema v2) instead of failing. Queued bubbles render
with visible Queued/Sending/Not sent states and flush strictly in
createdAt order once transport health recovers; each command's client
UUID rides as the send idempotency key, so at-least-once delivery plus
gateway dedupe keeps the transcript exact.
Contract summary:
- Bounds: 50 queued commands per gateway; refused enqueues keep the
draft. Queued rows older than 48h expire to failed("expired") rather
than silently sending stale commands; tap-to-retry refreshes
createdAt so an expired row can resend as new intent.
- Failure taxonomy: transport-level failures keep rows queued without
burning retry attempts (backoff ladder, then health drops so the
reconnect machinery owns pacing); gateway rejections burn attempts
and fail terminally after 3, with context-menu retry/delete.
- Deletes are tombstoned synchronously and rechecked after the claim
await so an active flush can never send a removed command; Delete is
hidden while a bubble is already in flight; offline enqueue is
guarded against double submit during the health probe.
- Post-reconnect live sends route behind draining outbox rows (FIFO),
and a cold open assumes a backlog until restore adopts durable rows.
- Crash safety: 'sending' rows revert to 'queued' at startup; flushed
turns are spliced into the session's cached transcript before their
outbox row is deleted, and stale history snapshots cannot evict a
just-flushed turn until a snapshot confirms it.
- Per-gateway scoping and purge ride the transcript cache: one SQLite
file per gateway; reset/forget drops the queue with the cache.
Fixture/unpaired transports get no outbox.
Part of #46664
* chore(ios): sync native i18n inventory
* style(chat-ui): satisfy strict SwiftFormat lint (doc comment, scope blank line)
134 lines
5.0 KiB
Swift
134 lines
5.0 KiB
Swift
import Foundation
|
|
|
|
// Thinking-level normalization and option resolution. Session entries,
|
|
// session defaults, and free-form user aliases all feed the picker; this
|
|
// extension owns collapsing them into the canonical option list.
|
|
|
|
extension OpenClawChatViewModel {
|
|
func syncThinkingLevelOptions() {
|
|
let currentSession = self.sessions.first(where: { $0.key == self.sessionKey })
|
|
var options = self.resolvedThinkingLevelOptions(for: currentSession)
|
|
if let current = Self.normalizedThinkingLevel(thinkingLevel) {
|
|
options = Self.withCurrentThinkingOption(options, current: current)
|
|
}
|
|
self.thinkingLevelOptions = options
|
|
}
|
|
|
|
private func resolvedThinkingLevelOptions(
|
|
for currentSession: OpenClawChatSessionEntry?) -> [OpenClawChatThinkingLevelOption]
|
|
{
|
|
if let levels = Self.normalizedThinkingLevelOptions(currentSession?.thinkingLevels), !levels.isEmpty {
|
|
return levels
|
|
}
|
|
|
|
let defaultsMatch = currentSession.map {
|
|
Self.sessionModelMatchesDefaults($0, defaults: self.sessionDefaults)
|
|
} ?? true
|
|
|
|
if defaultsMatch,
|
|
let levels = Self.normalizedThinkingLevelOptions(sessionDefaults?.thinkingLevels),
|
|
!levels.isEmpty
|
|
{
|
|
return levels
|
|
}
|
|
|
|
if let options = Self.thinkingOptions(from: currentSession?.thinkingOptions), !options.isEmpty {
|
|
return options
|
|
}
|
|
|
|
if defaultsMatch,
|
|
let options = Self.thinkingOptions(from: sessionDefaults?.thinkingOptions),
|
|
!options.isEmpty
|
|
{
|
|
return options
|
|
}
|
|
|
|
return Self.baseThinkingLevelOptions
|
|
}
|
|
|
|
private static func sessionModelMatchesDefaults(
|
|
_ session: OpenClawChatSessionEntry,
|
|
defaults: OpenClawChatSessionsDefaults?) -> Bool
|
|
{
|
|
let providerMatches = session.modelProvider == nil || session.modelProvider == defaults?.modelProvider
|
|
let modelMatches = session.model == nil || session.model == defaults?.model
|
|
return providerMatches && modelMatches
|
|
}
|
|
|
|
private static func normalizedThinkingLevelOptions(
|
|
_ levels: [OpenClawChatThinkingLevelOption]?) -> [OpenClawChatThinkingLevelOption]?
|
|
{
|
|
guard let levels else { return nil }
|
|
return Self.dedupedThinkingOptions(
|
|
levels.compactMap { level in
|
|
guard let id = Self.normalizedThinkingLevel(level.id) else { return nil }
|
|
let label = level.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return OpenClawChatThinkingLevelOption(id: id, label: label.isEmpty ? id : label)
|
|
})
|
|
}
|
|
|
|
private static func thinkingOptions(from labels: [String]?) -> [OpenClawChatThinkingLevelOption]? {
|
|
guard let labels else { return nil }
|
|
return Self.dedupedThinkingOptions(
|
|
labels.compactMap { label in
|
|
guard let id = Self.normalizedThinkingLevel(label) else { return nil }
|
|
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return OpenClawChatThinkingLevelOption(id: id, label: trimmed.isEmpty ? id : trimmed)
|
|
})
|
|
}
|
|
|
|
static func withCurrentThinkingOption(
|
|
_ options: [OpenClawChatThinkingLevelOption],
|
|
current: String) -> [OpenClawChatThinkingLevelOption]
|
|
{
|
|
guard !options.contains(where: { $0.id == current }) else { return options }
|
|
return options + [OpenClawChatThinkingLevelOption(id: current, label: current)]
|
|
}
|
|
|
|
private static func dedupedThinkingOptions(
|
|
_ options: [OpenClawChatThinkingLevelOption]) -> [OpenClawChatThinkingLevelOption]
|
|
{
|
|
var result: [OpenClawChatThinkingLevelOption] = []
|
|
var seen = Set<String>()
|
|
for option in options {
|
|
guard !option.id.isEmpty, !seen.contains(option.id) else { continue }
|
|
seen.insert(option.id)
|
|
result.append(option)
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func normalizedThinkingLevel(_ level: String?) -> String? {
|
|
guard let level else { return nil }
|
|
let trimmed = level.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let collapsed = trimmed.replacingOccurrences(
|
|
of: "[\\s_-]+",
|
|
with: "",
|
|
options: .regularExpression)
|
|
|
|
switch collapsed {
|
|
case "adaptive", "auto":
|
|
return "adaptive"
|
|
case "max":
|
|
return "max"
|
|
case "xhigh", "extrahigh":
|
|
return "xhigh"
|
|
case "off", "none":
|
|
return "off"
|
|
case "on", "enable", "enabled":
|
|
return "low"
|
|
case "min", "minimal", "think":
|
|
return "minimal"
|
|
case "low", "thinkhard":
|
|
return "low"
|
|
case "mid", "med", "medium", "thinkharder", "harder":
|
|
return "medium"
|
|
case "high", "ultra", "ultrathink", "thinkhardest", "highest":
|
|
return "high"
|
|
default:
|
|
return trimmed
|
|
}
|
|
}
|
|
}
|