Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift
Peter Steinberger df9a940872 feat(chat): show live run telemetry across clients (#113084)
* feat(chat): show live run telemetry across clients

* fix(chat): satisfy native validation gates

* fix(chat): settle advertised terminal runs

* fix(android): clear stale run usage on gaps

* chore(i18n): align native source inventory

* fix(chat): reconcile telemetry with current projections

* refactor(chat): keep run state in owner extensions

* chore(i18n): refresh native source inventory

* test(ui): expect persistent working telemetry through streaming
2026-07-29 01:27:58 -04:00

300 lines
10 KiB
Swift

import Foundation
enum ChatWorkingClawStance: CaseIterable, Equatable, Sendable {
case standard
case southpaw
case flurry
case spin
case shadowbox
case backflip
case zen
case drummer
case peekaboo
static let weightedStances: [(stance: Self, weight: Double)] = [
(.standard, 63),
(.southpaw, 19),
(.flurry, 5),
(.spin, 4),
(.shadowbox, 3),
(.backflip, 2),
(.zen, 2),
(.drummer, 1),
(.peekaboo, 1),
]
static func seeded(_ key: String, salt: UInt32) -> Self {
let totalWeight = self.weightedStances.reduce(0) { $0 + $1.weight }
var roll = Double((fnv1a(key) ^ salt) % 1000) / 1000 * totalWeight
for entry in self.weightedStances {
roll -= entry.weight
if roll <= 0 {
return entry.stance
}
}
return .standard
}
}
enum ChatWorkingPhrase {
static let showAfterMilliseconds = 30000
static let rotateEveryMilliseconds = 45000
/// Keep source phrases localizable; the post-merge native locale refresh
/// owns the generated catalog entries.
static let resources: [String] = [
String(localized: "Shelling"),
String(localized: "Scuttling"),
String(localized: "Clawing"),
String(localized: "Pinching"),
String(localized: "Molting"),
String(localized: "Bubbling"),
String(localized: "Tiding"),
String(localized: "Reefing"),
String(localized: "Cracking"),
String(localized: "Sifting"),
String(localized: "Brining"),
String(localized: "Nautiling"),
String(localized: "Krilling"),
String(localized: "Barnacling"),
String(localized: "Lobstering"),
String(localized: "Tidepooling"),
String(localized: "Pearling"),
String(localized: "Snapping"),
String(localized: "Surfacing"),
]
/// A constant-time stride walk keeps adjacent phrases distinct. The prime
/// list length makes every non-zero stride visit all phrases before repeating.
static func index(seed: String, elapsedMilliseconds: Int) -> Int? {
guard elapsedMilliseconds >= self.showAfterMilliseconds else { return nil }
let bucket = (elapsedMilliseconds - self.showAfterMilliseconds) / self.rotateEveryMilliseconds
return self.index(seed: seed, bucket: bucket)
}
static func index(seed: String, bucket: Int) -> Int {
let count = self.resources.count
let offset = Int(fnv1a("\(seed):offset") % UInt32(count))
let stride = 1 + Int(fnv1a("\(seed):stride") % UInt32(count - 1))
return (offset + bucket * stride) % count
}
}
enum ChatWorkingDurationFormatter {
private static let compactUnits = [
(suffix: "d", seconds: 86400),
(suffix: "h", seconds: 3600),
(suffix: "m", seconds: 60),
(suffix: "s", seconds: 1),
]
static func compact(milliseconds: Double) -> String {
let totalSeconds = self.totalSeconds(milliseconds: milliseconds)
var remainder = totalSeconds
var parts: [String] = []
for unit in self.compactUnits {
let value = remainder / unit.seconds
remainder %= unit.seconds
if value > 0 {
parts.append("\(value)\(unit.suffix)")
}
if parts.count == 2 {
break
}
}
return parts.joined(separator: " ")
}
static func full(milliseconds: Double, locale: Locale = .current) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.day, .hour, .minute, .second]
formatter.unitsStyle = .full
formatter.maximumUnitCount = 2
formatter.zeroFormattingBehavior = .dropAll
var calendar = Calendar(identifier: .gregorian)
calendar.locale = locale
formatter.calendar = calendar
return formatter.string(from: TimeInterval(self.totalSeconds(milliseconds: milliseconds))) ??
String(localized: "1 second", locale: locale)
}
private static func totalSeconds(milliseconds: Double) -> Int {
let finiteMilliseconds = milliseconds.isFinite ? milliseconds : 1000
let roundedSeconds = (max(1000, finiteMilliseconds) / 1000).rounded()
return max(1, Int(min(roundedSeconds, Double(Int.max / 2))))
}
}
enum ChatWorkingIdentity {
static func resolve(
sessionKey: String,
pendingRunIDs: Set<String>,
localUserMessageIDsByRunID: [String: UUID],
fallbackGeneration: UInt64) -> String
{
if let messageID = pendingRunIDs.compactMap({ localUserMessageIDsByRunID[$0]?.uuidString }).min() {
return "\(sessionKey):user:\(messageID)"
}
if let runID = pendingRunIDs.min() {
return "\(sessionKey):run:\(runID)"
}
return "\(sessionKey):generation:\(fallbackGeneration)"
}
}
struct ChatTurnRecap: Equatable, Sendable {
let runtimeMs: Double
let outputTokens: Int?
}
enum ChatTurnRecapText {
static func done(runtimeMs: Double, locale: Locale = .current) -> String {
String(
format: String(localized: "Done in %@", locale: locale),
locale: locale,
ChatWorkingDurationFormatter.full(milliseconds: runtimeMs, locale: locale))
}
static func tokens(_ outputTokens: Int?, locale: Locale = .current) -> String? {
guard let outputTokens else { return nil }
// Mirrors the web and American-English source contract: exactly one
// uses the singular key; zero and every other count use the generic key.
if outputTokens == 1 {
return String(localized: "1 token", locale: locale)
}
return String(
format: String(localized: "%@ tokens", locale: locale),
locale: locale,
outputTokens.formatted(.number.locale(locale)))
}
}
struct ChatTurnRecapSessionRow: Equatable, Sendable {
let status: String?
let endedAt: Double?
let runtimeMs: Double?
let outputTokens: Int?
init(
status: String? = nil,
endedAt: Double? = nil,
runtimeMs: Double? = nil,
outputTokens: Int? = nil)
{
self.status = status
self.endedAt = endedAt
self.runtimeMs = runtimeMs
self.outputTokens = outputTokens
}
init(_ entry: OpenClawChatSessionEntry) {
self.init(
status: entry.status,
endedAt: entry.endedAt,
runtimeMs: entry.runtimeMs,
outputTokens: entry.outputTokens)
}
}
/// Watches the turn whose working indicator was visible. Most anomalous
/// interleavings fail quiet; without a row run ID, an unrelated completion
/// inside the settle window remains an accepted cosmetic attribution risk.
struct ChatTurnRecapResolver {
private struct Watch {
var watching: Bool
var baselineKnown: Bool
var baselineEndedAt: Double?
var absorbedTerminal: Bool
var settleStartedAt: Date?
var settled: ChatTurnRecap?
}
private static let settleWindow: TimeInterval = 30
private var watches: [String: Watch] = [:]
mutating func resolve(
sessionKey: String,
indicatorVisible: Bool,
row: ChatTurnRecapSessionRow?,
now: Date = Date()) -> ChatTurnRecap?
{
var watch = self.watches[sessionKey]
let rowEndedAt = row?.endedAt
if indicatorVisible {
if watch == nil || watch?.watching == false {
watch = Watch(
watching: true,
baselineKnown: row != nil,
baselineEndedAt: rowEndedAt,
absorbedTerminal: false,
settleStartedAt: nil,
settled: nil)
} else if watch?.baselineKnown == false {
if row != nil {
watch?.baselineKnown = true
watch?.baselineEndedAt = rowEndedAt
}
} else if let rowEndedAt, rowEndedAt != watch?.baselineEndedAt {
watch?.baselineEndedAt = rowEndedAt
watch?.absorbedTerminal = true
}
self.watches[sessionKey] = watch
return nil
}
guard var watch else { return nil }
watch.watching = false
if let settled = watch.settled {
self.watches[sessionKey] = watch
return settled
}
if watch.absorbedTerminal || !watch.baselineKnown {
// Without an attributable baseline, later terminal rows could belong
// to background work, so consume this watch unresolved.
self.watches[sessionKey] = nil
return nil
}
if let settleStartedAt = watch.settleStartedAt {
if now.timeIntervalSince(settleStartedAt) > Self.settleWindow {
self.watches[sessionKey] = nil
return nil
}
} else {
watch.settleStartedAt = now
}
let isStale: Bool = if let rowEndedAt, let baselineEndedAt = watch.baselineEndedAt {
rowEndedAt <= baselineEndedAt
} else {
rowEndedAt == nil
}
if isStale {
// Terminal stamps are monotonic. Equality and regression both mean
// the watched run's terminal patch has not arrived yet.
self.watches[sessionKey] = watch
return nil
}
// Any fresh terminal concludes the watch. Only clean done rows with a
// finite runtime produce a recap; waiting longer could attach another run.
self.watches[sessionKey] = nil
guard row?.status == "done", let runtimeMs = row?.runtimeMs, runtimeMs.isFinite else {
return nil
}
let settled = ChatTurnRecap(runtimeMs: runtimeMs, outputTokens: row?.outputTokens)
watch.settled = settled
self.watches[sessionKey] = watch
return settled
}
}
private func fnv1a(_ key: String) -> UInt32 {
var hash: UInt32 = 0x811C_9DC5
for codeUnit in key.utf16 {
hash ^= UInt32(codeUnit)
hash = hash &* 0x0100_0193
}
return hash
}